mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
chore: remove LLM-slop comments across the codebase (#1277)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Max
Mari
Quinn
Sami
Perci
Tyler Longwell
parent
996a3f89d5
commit
73cc31cc52
@@ -19,8 +19,6 @@ use crate::observer::{ObserverContext, ObserverHandle};
|
||||
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
|
||||
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB
|
||||
|
||||
// ─── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
/// An MCP server configuration passed to `session/new`.
|
||||
///
|
||||
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
|
||||
@@ -106,8 +104,6 @@ pub enum AcpError {
|
||||
AgentError(String),
|
||||
}
|
||||
|
||||
// ─── AcpClient ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// ACP client that owns an agent subprocess and communicates over its stdio.
|
||||
///
|
||||
/// One `AcpClient` per agent process. Multiple sessions can be created on the
|
||||
@@ -151,8 +147,6 @@ pub struct AcpClient {
|
||||
}
|
||||
|
||||
impl AcpClient {
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Kill the agent subprocess and wait for it to exit (no zombies).
|
||||
///
|
||||
/// `Drop` only calls `start_kill()` (sends SIGKILL but doesn't reap).
|
||||
@@ -568,8 +562,6 @@ impl AcpClient {
|
||||
self.parse_stop_reason(&result)
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Serialize `value` as a single NDJSON line and flush to the agent's stdin.
|
||||
///
|
||||
/// Bounded by a 30-second write timeout. If the agent stops reading stdin
|
||||
@@ -1090,8 +1082,6 @@ impl AcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permission response constructors ────────────────────────────────────────
|
||||
|
||||
/// Build `session/prompt` params from one or more text content blocks.
|
||||
fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value {
|
||||
let blocks: Vec<serde_json::Value> = prompt_blocks
|
||||
@@ -1122,8 +1112,6 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Session response types ───────────────────────────────────────────────────
|
||||
|
||||
/// Full `session/new` response — session ID plus the raw JSON result.
|
||||
///
|
||||
/// Callers use the extractor helpers to pull model info from `raw`.
|
||||
@@ -1215,8 +1203,6 @@ pub fn resolve_model_switch_method(
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Drop: kill child process ─────────────────────────────────────────────────
|
||||
|
||||
impl Drop for AcpClient {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort SIGKILL + reap. We cannot `await` in Drop (sync context).
|
||||
@@ -1258,14 +1244,10 @@ fn kill_process_group(_pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── StopReason parsing ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stop_reason_parses_all_known_values() {
|
||||
assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn));
|
||||
@@ -1310,8 +1292,6 @@ mod tests {
|
||||
assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal));
|
||||
}
|
||||
|
||||
// ── Permission option finding ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn find_allow_once_by_kind_not_by_option_id() {
|
||||
// optionId values are intentionally non-obvious to prove we don't hardcode them.
|
||||
@@ -1371,8 +1351,6 @@ mod tests {
|
||||
assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x"));
|
||||
}
|
||||
|
||||
// ── JSON-RPC message construction ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn request_has_id_field() {
|
||||
let id: u64 = 42;
|
||||
@@ -1563,8 +1541,6 @@ mod tests {
|
||||
assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess_xyz789"));
|
||||
}
|
||||
|
||||
// ── String ID handling (Fix 1) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn permission_request_with_string_id() {
|
||||
// Verify that permission response uses the same ID type as the request.
|
||||
@@ -1617,8 +1593,6 @@ mod tests {
|
||||
assert!(cancelled_numeric["id"].is_number());
|
||||
}
|
||||
|
||||
// ── Model extractor tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_model_config_options_finds_model_category() {
|
||||
let result = serde_json::json!({
|
||||
@@ -1685,8 +1659,6 @@ mod tests {
|
||||
assert!(super::extract_model_state(&result).is_none());
|
||||
}
|
||||
|
||||
// ── resolve_model_switch_method tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_stable_over_unstable() {
|
||||
let result = serde_json::json!({
|
||||
@@ -1783,8 +1755,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error variant display ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_error_includes_duration() {
|
||||
let err = AcpError::IdleTimeout(std::time::Duration::from_secs(320));
|
||||
@@ -1805,8 +1775,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Async integration tests with real subprocess ──────────────────────
|
||||
|
||||
async fn spawn_script(script: &str) -> AcpClient {
|
||||
AcpClient::spawn("bash", &["-c".into(), script.into()], &[])
|
||||
.await
|
||||
@@ -1977,8 +1945,6 @@ mod tests {
|
||||
assert_eq!(result.unwrap()["worked"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
// ── Keepalive / tool-call idle reset tests (PR #935 fix) ─────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn keepalive_resets_idle_past_deadline() {
|
||||
// Keepalive session/update lines every 50ms against a 100ms idle deadline.
|
||||
@@ -2044,8 +2010,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── session_new_full systemPrompt serialization ──────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_full_includes_system_prompt_when_some() {
|
||||
// Script: respond to initialize, then echo back the session/new request.
|
||||
|
||||
@@ -13,8 +13,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::filter::SubscriptionRule;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Default idle timeout (seconds) when neither `--idle-timeout` nor the
|
||||
/// deprecated `--turn-timeout` is set.
|
||||
///
|
||||
@@ -26,8 +24,6 @@ use crate::filter::SubscriptionRule;
|
||||
/// Override via `--idle-timeout` / `BUZZ_ACP_IDLE_TIMEOUT`.
|
||||
pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900;
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to parse nostr keys: {0}")]
|
||||
@@ -40,8 +36,6 @@ pub enum ConfigError {
|
||||
ConfigFile(String),
|
||||
}
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
|
||||
pub enum SubscribeMode {
|
||||
Mentions,
|
||||
@@ -151,8 +145,6 @@ impl std::fmt::Display for PermissionMode {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Models subcommand ─────────────────────────────────────────────────────────
|
||||
|
||||
/// CLI args for `buzz-acp models` — query available models from an agent.
|
||||
///
|
||||
/// This is a standalone `Parser` (not a subcommand variant) because the
|
||||
@@ -182,8 +174,6 @@ pub struct ModelsArgs {
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "buzz-acp",
|
||||
@@ -416,8 +406,6 @@ pub struct CliArgs {
|
||||
pub relay_observer: bool,
|
||||
}
|
||||
|
||||
// ── Merged NIP-01 filter ──────────────────────────────────────────────────────
|
||||
|
||||
/// Merged NIP-01 subscription filter for a single channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelFilter {
|
||||
@@ -427,8 +415,6 @@ pub struct ChannelFilter {
|
||||
pub require_mention: bool,
|
||||
}
|
||||
|
||||
// ── Resolved config ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub keys: Keys,
|
||||
@@ -744,7 +730,6 @@ impl Config {
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Inbound author gate validation ──────────────────────────────────
|
||||
let respond_to_allowlist = if args.respond_to == RespondTo::Allowlist {
|
||||
let raw = args.respond_to_allowlist.unwrap_or_default();
|
||||
if raw.is_empty() {
|
||||
@@ -762,7 +747,6 @@ impl Config {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
// ── Persona pack resolution ──────────────────────────────────────────
|
||||
//
|
||||
// Precedence: CLI/env args > persona values > built-in defaults.
|
||||
// Persona fills in what's missing. Explicit flags always win.
|
||||
@@ -810,7 +794,6 @@ impl Config {
|
||||
}
|
||||
let model = args.model.or(persona_model);
|
||||
|
||||
// ── Multiple-event-handling validation ──────────────────────────────
|
||||
if matches!(
|
||||
args.multiple_event_handling,
|
||||
MultipleEventHandling::Interrupt | MultipleEventHandling::OwnerInterrupt
|
||||
@@ -900,8 +883,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOML config file ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct TomlConfig {
|
||||
#[serde(default)]
|
||||
@@ -985,8 +966,6 @@ pub fn load_rules(path: &std::path::Path) -> Result<Vec<SubscriptionRule>, Confi
|
||||
Ok(config.rules)
|
||||
}
|
||||
|
||||
// ── Subscription resolution ───────────────────────────────────────────────────
|
||||
|
||||
/// Resolve per-channel NIP-01 filters from config + discovered channels.
|
||||
pub fn resolve_channel_filters(
|
||||
config: &Config,
|
||||
@@ -1181,8 +1160,6 @@ fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1249,8 +1226,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolve_channel_filters: Mentions mode ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_mentions_mode_default_kinds() {
|
||||
let config = test_config(SubscribeMode::Mentions);
|
||||
@@ -1386,8 +1361,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── resolve_channel_filters: All mode ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_all_mode_wildcard() {
|
||||
let config = test_config(SubscribeMode::All);
|
||||
@@ -1416,8 +1389,6 @@ mod tests {
|
||||
assert_eq!(f.kinds.as_ref().unwrap(), &[9, 7]);
|
||||
}
|
||||
|
||||
// ── resolve_channel_filters: channels_override ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_channels_override_filters_to_discovered() {
|
||||
let mut config = test_config(SubscribeMode::All);
|
||||
@@ -1437,8 +1408,6 @@ mod tests {
|
||||
assert!(!result.contains_key(&ch_unknown));
|
||||
}
|
||||
|
||||
// ── resolve_channel_filters: Config mode ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_config_mode_single_rule_all_channels() {
|
||||
let config = test_config(SubscribeMode::Config);
|
||||
@@ -1543,8 +1512,6 @@ mod tests {
|
||||
assert!(!f.require_mention, "most permissive (false) should win");
|
||||
}
|
||||
|
||||
// ── rule_applies_to_channel ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_rule_applies_all() {
|
||||
let rule = make_rule("test", ChannelScope::All("all".into()), vec![], false);
|
||||
@@ -1580,8 +1547,6 @@ mod tests {
|
||||
assert!(!rule_applies_to_channel(&rule, Uuid::new_v4()));
|
||||
}
|
||||
|
||||
// ── load_rules validation ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_load_rules_valid_toml() {
|
||||
let dir = std::env::temp_dir().join("buzz-acp-test-valid");
|
||||
@@ -1726,8 +1691,6 @@ channels = "ALL"
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── heartbeat validation ─────────────────────────────────────────────────
|
||||
|
||||
fn validate_heartbeat_interval(secs: u64) -> Result<(), ConfigError> {
|
||||
if secs > 0 && secs < 10 {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
@@ -1770,8 +1733,6 @@ channels = "ALL"
|
||||
assert!(err.to_string().contains("heartbeat interval must be 0"));
|
||||
}
|
||||
|
||||
// ── turn-liveness validation ─────────────────────────────────────────────
|
||||
|
||||
fn validate_turn_liveness(secs: u64) -> Result<(), ConfigError> {
|
||||
if secs > 0 && secs < 5 {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
@@ -1808,8 +1769,6 @@ channels = "ALL"
|
||||
assert!(err.to_string().contains("turn liveness interval must be 0"));
|
||||
}
|
||||
|
||||
// ── summary includes agents and heartbeat ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_summary_includes_agents_and_heartbeat() {
|
||||
let config = test_config(SubscribeMode::Mentions);
|
||||
@@ -1840,8 +1799,6 @@ channels = "ALL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── memory toggle ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_memory_enabled_default_true() {
|
||||
let config = test_config(SubscribeMode::Mentions);
|
||||
@@ -1872,8 +1829,6 @@ channels = "ALL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── permission mode ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_permission_mode_wire_strings() {
|
||||
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
|
||||
@@ -1975,8 +1930,6 @@ channels = "ALL"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idle timeout config precedence ─────────────────────────────────────
|
||||
|
||||
/// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args.
|
||||
/// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`.
|
||||
fn resolve_idle_timeout(idle: Option<u64>, turn: Option<u64>) -> u64 {
|
||||
@@ -2033,8 +1986,6 @@ channels = "ALL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── RespondTo tests ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_respond_to_default_is_owner_only() {
|
||||
assert_eq!(RespondTo::default(), RespondTo::OwnerOnly);
|
||||
@@ -2091,8 +2042,6 @@ channels = "ALL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── validate_allowlist tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_allowlist_valid_entries() {
|
||||
let entries = vec!["ab".repeat(32), "cd".repeat(32)];
|
||||
@@ -2165,8 +2114,6 @@ channels = "ALL"
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// ── Idle timeout constant + guard (PR #935) ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_idle_timeout_is_900_seconds() {
|
||||
// Lock the constant value so accidental changes are caught.
|
||||
|
||||
@@ -11,8 +11,6 @@ use std::time::Duration;
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Errors that can occur during filter expression evaluation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FilterError {
|
||||
@@ -24,8 +22,6 @@ pub enum FilterError {
|
||||
EvalError(String),
|
||||
}
|
||||
|
||||
// ── FilterContext ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Variables extracted from a Nostr event for use in filter expressions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilterContext {
|
||||
@@ -54,8 +50,6 @@ impl FilterContext {
|
||||
}
|
||||
}
|
||||
|
||||
// ── SubscriptionRule ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Scope of channels a subscription rule applies to.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
@@ -151,8 +145,6 @@ impl Clone for SubscriptionRule {
|
||||
}
|
||||
}
|
||||
|
||||
// ── MatchedRule ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The result of a successful rule match.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchedRule {
|
||||
@@ -163,8 +155,6 @@ pub struct MatchedRule {
|
||||
pub prompt_tag: String,
|
||||
}
|
||||
|
||||
// ── evaluate_filter ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum expression length accepted by `evaluate_filter`.
|
||||
///
|
||||
/// Bounds worst-case O(2^n) evaluation paths. The spawn_blocking thread cannot
|
||||
@@ -276,7 +266,6 @@ fn build_eval_context(ctx: &FilterContext) -> Result<evalexpr::HashMapContext, S
|
||||
|
||||
let mut eval_ctx = HashMapContext::new();
|
||||
|
||||
// ── Custom string functions ───────────────────────────────────────────────
|
||||
// evalexpr v11 does not ship these helpers; register them manually.
|
||||
|
||||
eval_ctx
|
||||
@@ -325,8 +314,6 @@ fn build_eval_context(ctx: &FilterContext) -> Result<evalexpr::HashMapContext, S
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// ── Event variables ───────────────────────────────────────────────────────
|
||||
|
||||
eval_ctx
|
||||
.set_value("content".into(), Value::String(ctx.content.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -346,8 +333,6 @@ fn build_eval_context(ctx: &FilterContext) -> Result<evalexpr::HashMapContext, S
|
||||
Ok(eval_ctx)
|
||||
}
|
||||
|
||||
// ── match_event ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Consecutive timeout threshold before a rule is treated as disabled.
|
||||
///
|
||||
/// After this many back-to-back timeouts on a single rule, the rule is logged
|
||||
@@ -474,8 +459,6 @@ pub async fn match_event(
|
||||
None
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -525,8 +508,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── FilterContext ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_filter_context_from_event() {
|
||||
let event = make_event(9, "hello world");
|
||||
@@ -540,8 +521,6 @@ mod tests {
|
||||
assert_eq!(ctx.timestamp, event.created_at.as_secs());
|
||||
}
|
||||
|
||||
// ── evaluate_filter ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_filter_str_contains() {
|
||||
let event = make_event(9, "P1 incident in production");
|
||||
@@ -598,8 +577,6 @@ mod tests {
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
// ── match_event ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_event_first_match_wins() {
|
||||
let event = make_event(9, "hello");
|
||||
@@ -704,8 +681,6 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── ChannelScope ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_channel_scope_all() {
|
||||
let scope = ChannelScope::All("all".into());
|
||||
@@ -736,8 +711,6 @@ mod tests {
|
||||
assert!(!scope.matches(&id_c));
|
||||
}
|
||||
|
||||
// ── prompt_tag fallback ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prompt_tag_falls_back_to_name() {
|
||||
let event = make_event(9, "hello");
|
||||
@@ -756,8 +729,6 @@ mod tests {
|
||||
assert_eq!(matched.prompt_tag, "my-rule");
|
||||
}
|
||||
|
||||
// ── Fail-closed filter error handling (finding #25) ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_error_fails_closed_no_fallthrough() {
|
||||
// A broken filter on rule[0] must NOT fall through to rule[1].
|
||||
|
||||
@@ -38,8 +38,6 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Subcommand dispatch ───────────────────────────────────────────────────────
|
||||
|
||||
/// Check if argv[1] matches a subcommand name, before any clap parsing.
|
||||
///
|
||||
/// This avoids clap rejecting harness flags (like `--private-key`) that aren't
|
||||
@@ -55,8 +53,6 @@ fn is_subcommand(name: &str) -> bool {
|
||||
/// Timeout for the `buzz-acp models` subcommand (spawn + init + session/new).
|
||||
const MODELS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ── Presence helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Publish a kind:20001 presence update event via the WebSocket connection.
|
||||
///
|
||||
/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence
|
||||
@@ -81,8 +77,6 @@ async fn publish_presence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Owner resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the agent's owner pubkey at startup.
|
||||
///
|
||||
/// Priority:
|
||||
@@ -111,8 +105,6 @@ fn resolve_agent_owner(config: &Config) -> Option<String> {
|
||||
config.agent_owner.clone()
|
||||
}
|
||||
|
||||
// ── Owner cache ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cache for the agent's owner pubkey.
|
||||
///
|
||||
/// Owner is now provided via `--agent-owner` config flag (no REST lookup).
|
||||
@@ -948,7 +940,6 @@ impl Drop for RespawnGuard {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Finding #16: propagate_legacy_env_vars before tokio runtime ───────────────
|
||||
//
|
||||
// Sync env-var propagation must run before the tokio runtime starts so that
|
||||
// any child processes inherit the correct environment. This must happen in the
|
||||
@@ -966,7 +957,6 @@ async fn tokio_main() -> Result<()> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("failed to install rustls crypto provider");
|
||||
// ── Subcommand dispatch — before Config::from_cli() or any harness setup ──
|
||||
if is_subcommand("models") {
|
||||
// Strip the "models" token so clap doesn't reject it as a positional.
|
||||
// Keeps argv[0] (binary name) and passes everything after "models".
|
||||
@@ -1007,7 +997,6 @@ async fn tokio_main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 1: Spawn N ACP agent subprocesses and initialize ─────────────────
|
||||
//
|
||||
// Finding #10: one agent failing to start must not kill the whole pool.
|
||||
// We attempt each spawn under a 60-second timeout; failures are logged and
|
||||
@@ -1084,7 +1073,6 @@ async fn tokio_main() -> Result<()> {
|
||||
tracing::info!("agent_pool_ready agents={}", live_count);
|
||||
let mut pool = AgentPool::from_slots(agent_slots);
|
||||
|
||||
// ── Step 2: Connect to Buzz relay ──────────────────────────────────────
|
||||
//
|
||||
// Finding #22: capture a startup watermark BEFORE connecting to the relay.
|
||||
// This timestamp is used for membership notification replay (via
|
||||
@@ -1120,14 +1108,12 @@ async fn tokio_main() -> Result<()> {
|
||||
|
||||
tracing::info!("connected to relay at {}", config.relay_url);
|
||||
|
||||
// ── Step 2b: Subscribe to membership notifications ────────────────────────
|
||||
relay
|
||||
.subscribe_membership_notifications()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("membership notification subscribe error: {e}"))?;
|
||||
tracing::info!("subscribed to membership notifications");
|
||||
|
||||
// ── Step 2c: Set initial presence ─────────────────────────────────────────
|
||||
let presence_publisher = relay.event_publisher();
|
||||
let presence_keys = config.keys.clone();
|
||||
if config.presence_enabled {
|
||||
@@ -1137,7 +1123,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2d: Resolve agent owner ────────────────────────────────────────
|
||||
// Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag.
|
||||
let startup_owner: Option<String> = resolve_agent_owner(&config);
|
||||
if let Some(ref owner) = startup_owner {
|
||||
@@ -1201,7 +1186,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Discover channels and build subscription rules ────────────────
|
||||
let channel_info_map = relay
|
||||
.discover_channels()
|
||||
.await
|
||||
@@ -1247,7 +1231,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 4: Subscribe to channels ────────────────────────────────────────
|
||||
let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules);
|
||||
if channel_filters.is_empty() {
|
||||
tracing::warn!("no channel subscriptions resolved — agent will sit idle");
|
||||
@@ -1260,7 +1243,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: Build shared prompt context ──────────────────────────────────
|
||||
let dedup_mode = config.dedup_mode;
|
||||
let mut queue = EventQueue::new(dedup_mode);
|
||||
|
||||
@@ -1304,7 +1286,6 @@ async fn tokio_main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 6: Heartbeat timer ───────────────────────────────────────────────
|
||||
let mut heartbeat = if config.heartbeat_interval_secs > 0 {
|
||||
let interval = Duration::from_secs(config.heartbeat_interval_secs);
|
||||
Some(tokio::time::interval_at(
|
||||
@@ -1316,7 +1297,6 @@ async fn tokio_main() -> Result<()> {
|
||||
};
|
||||
let mut heartbeat_in_flight = false;
|
||||
|
||||
// ── Step 6b: Presence heartbeat timer (refreshes 90s TTL every 60s) ───────
|
||||
let mut presence_heartbeat = if config.presence_enabled {
|
||||
let interval = Duration::from_secs(60);
|
||||
Some(tokio::time::interval_at(
|
||||
@@ -1327,7 +1307,6 @@ async fn tokio_main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Step 6c: Typing refresh timer (re-publishes kind:20002 every 3s) ──────
|
||||
let mut typing_refresh = if config.typing_enabled {
|
||||
let interval = Duration::from_secs(3);
|
||||
Some(tokio::time::interval_at(
|
||||
@@ -1340,7 +1319,6 @@ async fn tokio_main() -> Result<()> {
|
||||
let mut typing_channels: HashMap<Uuid, ThreadTags> = HashMap::new();
|
||||
let mut presence_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
|
||||
// ── Step 6d: Maintenance (slot refill + queue compaction) ────────────────
|
||||
// Runs at the TOP of every loop iteration via Instant check — cannot be
|
||||
// starved by the biased select. Slot refill spawns background tasks so
|
||||
// spawn_and_init never blocks the main loop.
|
||||
@@ -1353,7 +1331,6 @@ async fn tokio_main() -> Result<()> {
|
||||
// JoinSet for respawn tasks so shutdown can abort them.
|
||||
let mut respawn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
|
||||
|
||||
// ── Step 7: Shutdown signal ───────────────────────────────────────────────
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(());
|
||||
|
||||
let tx = shutdown_tx.clone();
|
||||
@@ -1405,7 +1382,6 @@ async fn tokio_main() -> Result<()> {
|
||||
// and capture it in TaskMeta at dispatch time.
|
||||
let mut removed_channels: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
// ── Finding #14: Per-slot crash history for circuit breaker ───────────────
|
||||
//
|
||||
// One SlotCircuit per agent slot. crash_times entries are pruned to the last
|
||||
// CIRCUIT_BREAKER_WINDOW on each respawn attempt. The Vec is indexed by
|
||||
@@ -1419,7 +1395,6 @@ async fn tokio_main() -> Result<()> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Step 8: Main orchestration loop ──────────────────────────────────────
|
||||
//
|
||||
// Branches 1 & 2 both need to borrow `pool`, but they access different
|
||||
// fields (result_rx vs join_set). We use `rx_and_join_set()` to split the
|
||||
@@ -1430,7 +1405,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
|
||||
loop {
|
||||
// ── Maintenance (runs at loop top — cannot be starved by biased select) ──
|
||||
if last_maintenance.elapsed() >= maintenance_interval {
|
||||
last_maintenance = std::time::Instant::now();
|
||||
queue.compact_expired_state();
|
||||
@@ -1470,7 +1444,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect completed background respawns (non-blocking) ─────────────
|
||||
let mut respawn_collected = false;
|
||||
while let Ok(rr) = respawn_rx.try_recv() {
|
||||
crash_history[rr.index].respawn_in_flight = false;
|
||||
@@ -1552,7 +1525,6 @@ async fn tokio_main() -> Result<()> {
|
||||
Some(buzz_event) => {
|
||||
let kind_u32 = buzz_event.event.kind.as_u16() as u32;
|
||||
|
||||
// ── Membership notification handling ──────────────
|
||||
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION
|
||||
|| kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION
|
||||
{
|
||||
@@ -1659,14 +1631,12 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ── End membership notification handling ──────────
|
||||
|
||||
if config.ignore_self && buzz_event.event.pubkey.to_hex() == pubkey_hex {
|
||||
tracing::debug!(channel_id = %buzz_event.channel_id, "dropping self-authored event");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Shutdown command handling ─────────────────────
|
||||
// Check: kind:9, content "!shutdown", from owner, mentions THIS agent.
|
||||
let is_shutdown = is_owner_control_command(
|
||||
&buzz_event.event,
|
||||
@@ -1691,9 +1661,7 @@ async fn tokio_main() -> Result<()> {
|
||||
// Don't drop it — it's a regular message that happens to
|
||||
// contain "!shutdown" from a non-owner.
|
||||
}
|
||||
// ── End shutdown command handling ──────────────────
|
||||
|
||||
// ── Cancel command handling ──────────────────────
|
||||
// Mirrors !shutdown: kind:9, content "!cancel", from
|
||||
// owner, mentions THIS agent. Must be BEFORE
|
||||
// queue.push() — the event content is moved by push.
|
||||
@@ -1726,9 +1694,7 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
// Not from owner — fall through to normal prompt handling.
|
||||
}
|
||||
// ── End cancel command handling ───────────────────
|
||||
|
||||
// ── Rotate command handling ─────────────────────
|
||||
// Mirrors !shutdown / !cancel: kind:9, content
|
||||
// "!rotate", from owner, mentions THIS agent.
|
||||
//
|
||||
@@ -1773,9 +1739,7 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
// Not from owner — fall through to normal prompt handling.
|
||||
}
|
||||
// ── End rotate command handling ──────────────────
|
||||
|
||||
// ── Inbound author gate ──────────────────────────
|
||||
// Coarse security policy: drop events from disallowed
|
||||
// authors before they reach subscription rules or the
|
||||
// agent. Must be AFTER !shutdown (owner can always
|
||||
@@ -1807,7 +1771,6 @@ async fn tokio_main() -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// ── End inbound author gate ──────────────────────
|
||||
|
||||
let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await;
|
||||
let prompt_tag = match matched {
|
||||
@@ -1838,7 +1801,6 @@ async fn tokio_main() -> Result<()> {
|
||||
pool::reaction_add(&rc, &event_id_hex, "👀").await;
|
||||
});
|
||||
}
|
||||
// ── Multiple-event-handling mode gate ─────────────
|
||||
// Event is already queued. If mode requires it AND
|
||||
// the channel has an in-flight task, fire cancel.
|
||||
if accepted && queue.is_channel_in_flight(buzz_event.channel_id) {
|
||||
@@ -1860,7 +1822,6 @@ async fn tokio_main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// ── End mode gate ────────────────────────────────
|
||||
for (channel_id, thread_tags) in
|
||||
dispatch_pending(&mut pool, &mut queue, &ctx)
|
||||
{
|
||||
@@ -2016,7 +1977,6 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shutdown sequence ─────────────────────────────────────────────────────
|
||||
tracing::info!("shutdown: waiting for in-flight prompts");
|
||||
// 30 s is generous for in-flight prompts to be cancelled; using
|
||||
// max_turn_duration here would cause Ctrl+C to hang for up to an hour.
|
||||
@@ -2117,16 +2077,12 @@ async fn tokio_main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Loop control ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum LoopAction {
|
||||
Continue,
|
||||
Exit,
|
||||
}
|
||||
|
||||
// ── Owner control commands ───────────────────────────────────────────────────
|
||||
|
||||
fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool {
|
||||
event.tags.iter().any(|t| {
|
||||
t.as_slice().first().map(|s| s.as_str()) == Some("p")
|
||||
@@ -2145,8 +2101,6 @@ fn is_owner_control_command(
|
||||
&& event_mentions_agent(event, agent_pubkey_hex)
|
||||
}
|
||||
|
||||
// ── signal_in_flight_task ─────────────────────────────────────────────────────
|
||||
|
||||
/// Send a control signal to the in-flight task for `channel_id`.
|
||||
/// Returns `true` if a signal was sent, `false` if no in-flight task was found.
|
||||
fn signal_in_flight_task(
|
||||
@@ -2169,8 +2123,6 @@ fn signal_in_flight_task(
|
||||
false
|
||||
}
|
||||
|
||||
// ── dispatch_pending ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Flush queued work to available agents.
|
||||
fn dispatch_pending(
|
||||
pool: &mut AgentPool,
|
||||
@@ -2246,8 +2198,6 @@ fn dispatch_pending(
|
||||
dispatched_channels
|
||||
}
|
||||
|
||||
// ── handle_prompt_result ──────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_prompt_result(
|
||||
pool: &mut AgentPool,
|
||||
@@ -2442,8 +2392,6 @@ fn handle_prompt_result(
|
||||
LoopAction::Continue
|
||||
}
|
||||
|
||||
// ── recover_panicked_agent ────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn recover_panicked_agent(
|
||||
pool: &mut AgentPool,
|
||||
@@ -2540,8 +2488,6 @@ fn recover_panicked_agent(
|
||||
});
|
||||
}
|
||||
|
||||
// ── drain_ready_join_results ──────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drain_ready_join_results(
|
||||
pool: &mut AgentPool,
|
||||
@@ -2579,8 +2525,6 @@ fn drain_ready_join_results(
|
||||
LoopAction::Continue
|
||||
}
|
||||
|
||||
// ── dispatch_heartbeat ────────────────────────────────────────────────────────
|
||||
|
||||
fn dispatch_heartbeat(
|
||||
pool: &mut AgentPool,
|
||||
ctx: &Arc<PromptContext>,
|
||||
@@ -2623,8 +2567,6 @@ fn dispatch_heartbeat(
|
||||
tracing::info!(agent = agent_index, "heartbeat_fired");
|
||||
}
|
||||
|
||||
// ── default_heartbeat_prompt ──────────────────────────────────────────────────
|
||||
|
||||
fn default_heartbeat_prompt() -> String {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
format!(
|
||||
@@ -2644,8 +2586,6 @@ fn default_heartbeat_prompt() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
// ── respawn_agent_into ────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn a background respawn task for a crashed agent slot.
|
||||
///
|
||||
/// Does the circuit breaker check synchronously (non-blocking), then spawns
|
||||
@@ -2703,8 +2643,6 @@ fn spawn_respawn_task(
|
||||
true
|
||||
}
|
||||
|
||||
// ── spawn_and_init ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn an agent subprocess and run the MCP `initialize` handshake.
|
||||
///
|
||||
/// Takes owned args so it can run in a background `tokio::spawn` task without
|
||||
@@ -2744,10 +2682,6 @@ async fn spawn_and_init(
|
||||
}
|
||||
}
|
||||
|
||||
// ── build_mcp_servers ─────────────────────────────────────────────────────────
|
||||
|
||||
// ── run_models ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `buzz-acp models` — spawn an agent, query its available models, exit.
|
||||
///
|
||||
/// Flow: spawn → initialize → session/new → print models → shutdown.
|
||||
@@ -2932,8 +2866,6 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
|
||||
}]
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod heartbeat_base_prompt_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -40,12 +40,9 @@ use crate::queue::{
|
||||
};
|
||||
use crate::relay::{ChannelInfo, RestClient};
|
||||
|
||||
// ── FlushBatch Clone note ─────────────────────────────────────────────────────
|
||||
// FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store
|
||||
// a recoverable copy in TaskMeta for panic recovery in Queue mode.
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Metadata stored per in-flight task for panic recovery.
|
||||
pub struct TaskMeta {
|
||||
pub agent_index: usize,
|
||||
@@ -255,8 +252,6 @@ pub struct PromptContext {
|
||||
pub memory_enabled: bool,
|
||||
}
|
||||
|
||||
// ── AgentPool impl ────────────────────────────────────────────────────────────
|
||||
|
||||
impl AgentPool {
|
||||
/// Create a pool from pre-indexed slots (may contain None for failed startups).
|
||||
///
|
||||
@@ -339,8 +334,6 @@ impl AgentPool {
|
||||
idle + checked_out
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn task_map(&self) -> &HashMap<tokio::task::Id, TaskMeta> {
|
||||
&self.task_map
|
||||
}
|
||||
@@ -405,8 +398,6 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── run_prompt_task ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Timeout for a single pre-prompt context fetch attempt (thread/DM history).
|
||||
/// Each call gets this budget; with one retry the total worst-case is
|
||||
/// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s.
|
||||
@@ -754,8 +745,6 @@ pub async fn run_prompt_task(
|
||||
result_tx: mpsc::UnboundedSender<PromptResult>,
|
||||
control_rx: Option<tokio::sync::oneshot::Receiver<ControlSignal>>,
|
||||
) {
|
||||
// ── Determine source and resolve/create session ───────────────────────
|
||||
|
||||
// Is this a channel prompt or a heartbeat?
|
||||
let source = match &batch {
|
||||
Some(b) => PromptSource::Channel(b.channel_id),
|
||||
@@ -786,7 +775,6 @@ pub async fn run_prompt_task(
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Reaction cleanup guard ────────────────────────────────────────────
|
||||
// Collects event IDs up front. On drop (any exit path — normal, early
|
||||
// return, or panic), spawns best-effort cleanup of both 👀 and 💬.
|
||||
// See `ReactionGuard` docs for ordering guarantees and known edge cases.
|
||||
@@ -796,7 +784,6 @@ pub async fn run_prompt_task(
|
||||
.unwrap_or_default();
|
||||
let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone());
|
||||
|
||||
// ── Turn completion guard ─────────────────────────────────────────────
|
||||
// Emits `turn_completed` on any exit path. Captures observer handle and
|
||||
// metadata now, before the agent is moved into PromptResult.
|
||||
let _turn_guard = TurnCompletionGuard::new(
|
||||
@@ -806,7 +793,6 @@ pub async fn run_prompt_task(
|
||||
turn_id.clone(),
|
||||
);
|
||||
|
||||
// ── NIP-AE: fetch core engram before session creation ───────────────
|
||||
//
|
||||
// Core memory is delivered inside the system prompt the harness already
|
||||
// builds (system role for protocol >= 2, the `[System]` user-message
|
||||
@@ -966,8 +952,6 @@ pub async fn run_prompt_task(
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Send initial_message on new channel sessions ──────────────────────
|
||||
|
||||
if is_new_session {
|
||||
if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message)
|
||||
{
|
||||
@@ -1080,8 +1064,6 @@ pub async fn run_prompt_task(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build prompt text (with optional context fetch) ──────────────────
|
||||
|
||||
// When the batch is a single slash-command message (e.g. "@Eva /goal …"),
|
||||
// `slash_command` holds the bare command. It is sent as the FIRST prompt
|
||||
// content block so ACP connectors' slash-command detection
|
||||
@@ -1163,8 +1145,6 @@ pub async fn run_prompt_task(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Send the actual prompt ────────────────────────────────────────────
|
||||
|
||||
// Slash-command pass-through sends the bare command as the first text
|
||||
// block (so connector detection fires), then each prompt section as its
|
||||
// own block. Per-section blocks let the observer size trimmer elide a
|
||||
@@ -1177,7 +1157,6 @@ pub async fn run_prompt_task(
|
||||
None => prompt_sections.iter().map(String::as_str).collect(),
|
||||
};
|
||||
|
||||
// ── Control-aware prompt dispatch ─────────────────────────────────────
|
||||
// When control_rx is Some (channel tasks), wrap the prompt in select! so
|
||||
// the main loop can cancel, interrupt, or rotate it. Heartbeats
|
||||
// (control_rx=None) take the simple await path — they are not controllable.
|
||||
@@ -1343,13 +1322,11 @@ pub async fn run_prompt_task(
|
||||
Ok(stop_reason) => {
|
||||
log_stop_reason(&source, &stop_reason);
|
||||
|
||||
// ── Session rotation on context exhaustion ────────────────
|
||||
let should_rotate = matches!(
|
||||
stop_reason,
|
||||
StopReason::MaxTokens | StopReason::MaxTurnRequests
|
||||
);
|
||||
|
||||
// ── Proactive turn-based rotation ─────────────────────────
|
||||
let should_rotate = should_rotate || {
|
||||
let limit = ctx.max_turns_per_session;
|
||||
if limit > 0 {
|
||||
@@ -1478,8 +1455,6 @@ pub async fn run_prompt_task(
|
||||
// _reaction_guard drops here → spawns clear_reactions for all exit paths.
|
||||
}
|
||||
|
||||
// ── Context fetching ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Retry wrapper for context fetches: one retry with `CONTEXT_FETCH_RETRY_DELAY`
|
||||
/// on any `None` result. The closure is called twice at most.
|
||||
///
|
||||
@@ -2031,8 +2006,6 @@ fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option<Conver
|
||||
})
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the batch for requeue only in Queue mode; drop it in Drop mode.
|
||||
#[inline]
|
||||
fn requeue_batch_if_queue(ctx: &PromptContext, batch: Option<FlushBatch>) -> Option<FlushBatch> {
|
||||
@@ -2067,7 +2040,6 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reaction indicators ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Two-phase lifecycle visible to users:
|
||||
// 👀 "seen" — event was queued and an agent will handle it
|
||||
@@ -2129,7 +2101,6 @@ impl Drop for ReactionGuard {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Turn liveness emission ───────────────────────────────────────────────────
|
||||
// Periodically emits a `turn_liveness` observer event while a turn is in-flight,
|
||||
// so the desktop can prune turns whose host died without unwinding (kill -9 /
|
||||
// crash) far sooner than the no-activity backstop. Runs as a non-resolving
|
||||
@@ -2172,7 +2143,6 @@ async fn run_turn_liveness(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Turn completion scope guard ──────────────────────────────────────────────
|
||||
// Emits a `turn_completed` observer event on drop, covering ALL exit paths
|
||||
// (success, error, timeout, cancel, panic) from `run_prompt_task`. Captures
|
||||
// observer handle and metadata at creation time so it remains valid even after
|
||||
@@ -2388,15 +2358,12 @@ async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec<String>)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use serde_json::json;
|
||||
|
||||
// ── prepend_base_for_legacy regression tests ─────────────────────────────
|
||||
// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
|
||||
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
|
||||
// message. This is the exact regression that shipped in the round-2 bug.
|
||||
@@ -2425,7 +2392,6 @@ mod tests {
|
||||
assert_eq!(composed, "hello channel");
|
||||
}
|
||||
|
||||
// ── framed_system_prompt tests ───────────────────────────────────────────
|
||||
// Pin the session/new systemPrompt framing: each present prompt carries its
|
||||
// own header so the desktop observer can split into labeled sub-sections.
|
||||
|
||||
@@ -2493,8 +2459,6 @@ mod tests {
|
||||
assert!(workspace_section("").is_none());
|
||||
}
|
||||
|
||||
// ── with_core tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_with_core_appends_below_framed() {
|
||||
let framed = with_core(
|
||||
@@ -2527,8 +2491,6 @@ mod tests {
|
||||
assert!(with_core(None, None).is_none());
|
||||
}
|
||||
|
||||
// ── parse_thread_response tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_parse_thread_response_basic() {
|
||||
let json = json!({
|
||||
@@ -2618,8 +2580,6 @@ mod tests {
|
||||
assert!(parse_thread_response(json).is_none());
|
||||
}
|
||||
|
||||
// ── parse_dm_response tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_parse_dm_response_basic() {
|
||||
let json = json!({
|
||||
@@ -2730,8 +2690,6 @@ mod tests {
|
||||
assert!(parse_dm_response(json, 12).is_none());
|
||||
}
|
||||
|
||||
// ── json_to_context_message tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_json_to_context_message_integer_timestamp() {
|
||||
let obj = json!({
|
||||
@@ -2843,8 +2801,6 @@ mod tests {
|
||||
assert_eq!(msg.pubkey, "unknown");
|
||||
}
|
||||
|
||||
// ── pct_encode tests ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_pct_encode_hex_passthrough() {
|
||||
let hex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||||
@@ -2880,8 +2836,6 @@ mod tests {
|
||||
assert_eq!(pct_encode(" "), "%20");
|
||||
}
|
||||
|
||||
// ── SessionState tests ───────────────────────────────────────────────
|
||||
|
||||
fn make_state() -> (SessionState, Uuid, Uuid) {
|
||||
let ch_a = Uuid::new_v4();
|
||||
let ch_b = Uuid::new_v4();
|
||||
@@ -3048,7 +3002,6 @@ mod tests {
|
||||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||||
}
|
||||
|
||||
// ── turn liveness emission ───────────────────────────────────────────────
|
||||
// `run_turn_liveness` is raced against a "prompt" future the same way
|
||||
// `run_prompt_task` does it: the prompt wins the select and the liveness
|
||||
// future is dropped. We assert what the observer saw.
|
||||
|
||||
@@ -20,8 +20,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::config::DedupMode;
|
||||
|
||||
// ── Reliability constants ─────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum events queued per channel before oldest events are dropped.
|
||||
const MAX_PENDING_PER_CHANNEL: usize = 500;
|
||||
|
||||
@@ -40,8 +38,6 @@ const MAX_RETRY_DELAY_SECS: u64 = 300;
|
||||
/// In-flight deadline: max_turn (3600s) + 100s buffer.
|
||||
const IN_FLIGHT_DEADLINE_SECS: u64 = 3700;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An event waiting in the queue.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueuedEvent {
|
||||
@@ -71,8 +67,6 @@ pub struct FlushBatch {
|
||||
pub cancelled_events: Vec<BatchEvent>,
|
||||
}
|
||||
|
||||
// ── EventQueue ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-channel event queue with per-channel in-flight enforcement.
|
||||
///
|
||||
/// # State Machine
|
||||
@@ -564,8 +558,6 @@ impl Default for EventQueue {
|
||||
}
|
||||
}
|
||||
|
||||
// ── NIP-10 tag parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/// Parsed thread relationship from NIP-10 `e` tags.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ThreadTags {
|
||||
@@ -628,8 +620,6 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash command detection ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract a leading slash command from message content.
|
||||
///
|
||||
/// ACP connectors (claude-agent-acp, codex-acp) detect slash commands by
|
||||
@@ -709,8 +699,6 @@ pub fn slash_command_for_batch(batch: &FlushBatch, known_names: &[&str]) -> Opti
|
||||
extract_slash_command(&batch.events[0].event.content, known_names)
|
||||
}
|
||||
|
||||
// ── Prompt formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Conversation context fetched by the harness before prompting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConversationContext {
|
||||
@@ -1183,8 +1171,6 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
|
||||
sections
|
||||
}
|
||||
|
||||
// ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1238,8 +1224,6 @@ mod tests {
|
||||
assert_eq!(base_section(" line1\nline2 "), "[Base]\n line1\nline2");
|
||||
}
|
||||
|
||||
// ── Test 1: push + flush_next basic ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_push_flush_basic() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1257,8 +1241,6 @@ mod tests {
|
||||
assert_eq!(q.queues.len(), 0);
|
||||
}
|
||||
|
||||
// ── Test 2: same channel cannot be flushed twice ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_in_flight_blocks_same_channel() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1276,8 +1258,6 @@ mod tests {
|
||||
assert!(q.flush_next().is_none());
|
||||
}
|
||||
|
||||
// ── Test 3: mark_complete enables flush ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_mark_complete_enables_flush() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1301,8 +1281,6 @@ mod tests {
|
||||
assert_eq!(batch.events[0].event.content, "second");
|
||||
}
|
||||
|
||||
// ── Test 4: batch drain ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_batch_drain_all_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1326,8 +1304,6 @@ mod tests {
|
||||
assert_eq!(q.queues.len(), 0);
|
||||
}
|
||||
|
||||
// ── Test 5: FIFO fairness ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_fifo_fairness_picks_oldest_channel() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1344,8 +1320,6 @@ mod tests {
|
||||
assert_eq!(batch.events[0].event.content, "from A");
|
||||
}
|
||||
|
||||
// ── Test 6: multi-channel interleave ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_multi_channel_interleave() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1375,16 +1349,12 @@ mod tests {
|
||||
assert_eq!(pending_count(&q), 0);
|
||||
}
|
||||
|
||||
// ── Test 7: empty queue returns None ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_empty_queue_returns_none() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
assert!(q.flush_next().is_none());
|
||||
}
|
||||
|
||||
// ── Test 9: format_prompt single event ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_single() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1419,8 +1389,6 @@ mod tests {
|
||||
assert!(!prompt.contains("--- Event 1 ---"));
|
||||
}
|
||||
|
||||
// ── Test 9b: requeue preserves events ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_preserves_events() {
|
||||
let mut queue = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1469,8 +1437,6 @@ mod tests {
|
||||
assert_eq!(next_batch.channel_id, ch_b);
|
||||
}
|
||||
|
||||
// ── Test 10: format_prompt batch ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_batch() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1512,8 +1478,6 @@ mod tests {
|
||||
assert!(prompt.contains("Content: third message"));
|
||||
}
|
||||
|
||||
// ── Test 11: system prompt NOT in user message (delivered via system role) ──
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_no_system_prompt_in_user_message() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1537,8 +1501,6 @@ mod tests {
|
||||
assert!(prompt.starts_with("[Context]"));
|
||||
}
|
||||
|
||||
// ── Test 11b: agent_core section is first in user message ──────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_with_agent_core() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1624,8 +1586,6 @@ mod tests {
|
||||
assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"));
|
||||
}
|
||||
|
||||
// ── Test 11c: base_prompt and system_prompt NOT in user message ────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_no_base_or_system_sections() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1649,8 +1609,6 @@ mod tests {
|
||||
assert!(prompt.starts_with("[Context]"));
|
||||
}
|
||||
|
||||
// ── Test 11d: legacy agents receive [Base]/[System] in user message ───────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_legacy_agent_emits_base_and_system() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1706,8 +1664,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 11e: modern agents suppress [Base]/[System] from user message ────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_modern_agent_suppresses_base_and_system() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -1803,8 +1759,6 @@ mod tests {
|
||||
assert!(!prompt.contains("[System]"));
|
||||
}
|
||||
|
||||
// ── Test 12: drop mode discards in-flight channel events ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_drop_mode_discards_in_flight_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Drop);
|
||||
@@ -1823,8 +1777,6 @@ mod tests {
|
||||
assert!(q.flush_next().is_none());
|
||||
}
|
||||
|
||||
// ── Test 13: drop mode still queues other channels ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_drop_mode_queues_other_channels() {
|
||||
let mut q = EventQueue::new(DedupMode::Drop);
|
||||
@@ -1844,8 +1796,6 @@ mod tests {
|
||||
assert_eq!(batch_b.channel_id, ch_b);
|
||||
}
|
||||
|
||||
// ── Test 14: multiple channels can be in-flight simultaneously ────────────
|
||||
|
||||
#[test]
|
||||
fn test_multiple_channels_in_flight_simultaneously() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1876,8 +1826,6 @@ mod tests {
|
||||
assert!(!any_in_flight(&q));
|
||||
}
|
||||
|
||||
// ── Test 15: same channel cannot be flushed twice ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_same_channel_not_flushed_twice() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1900,8 +1848,6 @@ mod tests {
|
||||
assert!(q.flush_next().is_none());
|
||||
}
|
||||
|
||||
// ── Test 16: drop mode drops events for any in-flight channel ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_drop_mode_drops_for_any_in_flight_channel() {
|
||||
let mut q = EventQueue::new(DedupMode::Drop);
|
||||
@@ -1924,8 +1870,6 @@ mod tests {
|
||||
q.mark_complete(ch_b);
|
||||
}
|
||||
|
||||
// ── Test 17: flush_next picks oldest non-in-flight, non-throttled channel ─
|
||||
|
||||
#[test]
|
||||
fn test_flush_next_picks_oldest_non_throttled() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1958,8 +1902,6 @@ mod tests {
|
||||
q.mark_complete(ch_c);
|
||||
}
|
||||
|
||||
// ── Test 18: mark_complete(channel_id) clears only that channel ───────────
|
||||
|
||||
#[test]
|
||||
fn test_mark_complete_clears_only_specified_channel() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -1987,8 +1929,6 @@ mod tests {
|
||||
assert!(!any_in_flight(&q));
|
||||
}
|
||||
|
||||
// ── Test 19: requeue_preserve_timestamps preserves received_at ───────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_preserve_timestamps() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2014,8 +1954,6 @@ mod tests {
|
||||
assert_eq!(batch2.events[0].received_at, original_received_at);
|
||||
}
|
||||
|
||||
// ── Test 20: requeue_preserve_timestamps does not set retry_after ─────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_preserve_timestamps_no_retry_after() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2032,8 +1970,6 @@ mod tests {
|
||||
assert!(q.flush_next().is_some());
|
||||
}
|
||||
|
||||
// ── Test 20b: requeue_preserve_timestamps enforces per-channel cap ────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_preserve_timestamps_enforces_cap() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2070,8 +2006,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 20c: requeue_preserve overflow trims newest, keeps requeued ─────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_preserve_timestamps_overflow_keeps_requeued_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2107,8 +2041,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 21: has_flushable_work returns correct results ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_has_flushable_work() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2147,8 +2079,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 22: retry throttle blocks re-flush for 5 seconds ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_retry_throttle_blocks_requeue_channel() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2180,8 +2110,6 @@ mod tests {
|
||||
assert_eq!(batch3.channel_id, ch);
|
||||
}
|
||||
|
||||
// ── NIP-10 tag parsing tests ─────────────────────────────────────────────
|
||||
|
||||
/// Build an event with specific tags for thread testing.
|
||||
fn make_event_with_tags(content: &str, tags: Vec<Vec<String>>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
@@ -2260,8 +2188,6 @@ mod tests {
|
||||
assert_eq!(tags.parent_event_id.as_deref(), Some("root123"));
|
||||
}
|
||||
|
||||
// ── Context formatting tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_with_channel_info() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -2722,8 +2648,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── drain_channel tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_drain_channel_removes_pending_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2794,8 +2718,6 @@ mod tests {
|
||||
assert!(any_in_flight(&q)); // in-flight unaffected
|
||||
}
|
||||
|
||||
// ── compact_expired_state ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_compact_cleans_orphaned_retry_counts() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2874,8 +2796,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test: requeue_as_cancelled merges into flush_next ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_as_cancelled_merges_in_flush_next() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2904,8 +2824,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test: requeue_as_cancelled fallback (no new events) ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_requeue_as_cancelled_no_new_events_fallback() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2932,8 +2850,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test: has_flushable_work accounts for cancelled_batches ──────────────
|
||||
|
||||
#[test]
|
||||
fn test_has_flushable_work_with_cancelled_only() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2952,8 +2868,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test: drain_channel clears cancelled_batches ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_drain_channel_clears_cancelled_batches() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -2975,8 +2889,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test: double-cancel accumulates all events ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_double_cancel_preserves_all_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
@@ -3018,8 +2930,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── reply instruction tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_reply_instruction_present_for_channel_thread_reply() {
|
||||
let ch = Uuid::new_v4();
|
||||
@@ -3288,8 +3198,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slash command extraction ──────────────────────────────────────────────
|
||||
|
||||
/// Build a single-event FlushBatch with the given content.
|
||||
fn make_single_batch(content: &str) -> FlushBatch {
|
||||
FlushBatch {
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::time::Duration;
|
||||
|
||||
// ─── Named constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Default capacity of the event channel from background task to harness.
|
||||
/// Override with `BUZZ_ACP_EVENT_BUFFER` env var at startup.
|
||||
const EVENT_CHANNEL_CAPACITY_DEFAULT: usize = 256;
|
||||
@@ -76,8 +74,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::config::ChannelFilter;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Metadata about a channel, populated at discovery time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelInfo {
|
||||
@@ -197,8 +193,6 @@ fn unix_now_secs() -> u64 {
|
||||
}
|
||||
|
||||
impl RestClient {
|
||||
// ── NIP-98 signing ────────────────────────────────────────────────────
|
||||
|
||||
/// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body.
|
||||
///
|
||||
/// Returns the `Authorization: Nostr <base64>` header value (without the
|
||||
@@ -247,8 +241,6 @@ impl RestClient {
|
||||
Ok(format!("Nostr {}", self.sign_nip98(method, url, body)?))
|
||||
}
|
||||
|
||||
// ── Retry helper ──────────────────────────────────────────────────────
|
||||
|
||||
/// Retry helper: executes `build_request` up to 4 times (1 attempt + 3 retries)
|
||||
/// on transient failures (429, 502, 503, 504, timeout, connect errors).
|
||||
///
|
||||
@@ -306,8 +298,6 @@ impl RestClient {
|
||||
.unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries"))))
|
||||
}
|
||||
|
||||
// ── Bridge methods ────────────────────────────────────────────────────
|
||||
|
||||
/// POST with NIP-98 auth and retry. Re-signs on each attempt.
|
||||
async fn bridge_post(
|
||||
&self,
|
||||
@@ -410,8 +400,6 @@ impl From<nostr::event::builder::Error> for RelayError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal relay message types ──────────────────────────────────────────────
|
||||
|
||||
/// A parsed NIP-01 relay message.
|
||||
#[derive(Debug, Clone)]
|
||||
enum RelayMessage {
|
||||
@@ -439,8 +427,6 @@ enum RelayMessage {
|
||||
},
|
||||
}
|
||||
|
||||
// ── Commands sent from HarnessRelay to the background task ───────────────────
|
||||
|
||||
/// Subscription ID for the global membership notification subscription.
|
||||
const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif";
|
||||
/// Subscription ID for encrypted owner-to-agent observer control frames.
|
||||
@@ -472,12 +458,8 @@ enum RelayCommand {
|
||||
SetStartupWatermark { ts: u64 },
|
||||
}
|
||||
|
||||
// ── WebSocket stream type alias ───────────────────────────────────────────────
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
// ── HarnessRelay ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Harness-side relay client.
|
||||
///
|
||||
/// Connects to the Buzz relay, authenticates via NIP-42, and streams
|
||||
@@ -525,8 +507,6 @@ impl RelayEventPublisher {
|
||||
}
|
||||
|
||||
impl HarnessRelay {
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Connect to relay and authenticate via NIP-42.
|
||||
///
|
||||
/// `auth_tag` is an optional NIP-OA owner attestation included in the AUTH
|
||||
@@ -858,8 +838,6 @@ impl Drop for HarnessRelay {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Background task ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Two-generation dedup set with bounded memory.
|
||||
///
|
||||
/// Mitigates the "amnesia window" caused by clearing the entire set at once.
|
||||
@@ -1371,7 +1349,6 @@ async fn run_background_task(
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
// ── Incoming WebSocket message ────────────────────────────────────
|
||||
raw = ws.next() => {
|
||||
// Determine if the socket is lost.
|
||||
let socket_lost = match raw {
|
||||
@@ -1454,7 +1431,6 @@ async fn run_background_task(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command from HarnessRelay ─────────────────────────────────────
|
||||
cmd = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(RelayCommand::Reconnect) => {
|
||||
@@ -1526,7 +1502,6 @@ async fn run_background_task(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Finding #31: client-initiated ping ────────────────────────────
|
||||
_ = ping_interval.tick() => {
|
||||
if ping_sent && last_pong.elapsed() > PONG_TIMEOUT {
|
||||
// No pong received after our last ping — connection is dead.
|
||||
@@ -2576,8 +2551,6 @@ async fn send_auth_response(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Free functions ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a WebSocket URL to its HTTP equivalent.
|
||||
///
|
||||
/// `ws://host:port` → `http://host:port`
|
||||
@@ -2737,8 +2710,6 @@ pub(crate) fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayError
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform a single WebSocket connect + NIP-42 auth handshake.
|
||||
///
|
||||
/// Returns `(ws, buffer)` on success.
|
||||
@@ -2760,13 +2731,10 @@ async fn do_connect(
|
||||
let mut ws = ws;
|
||||
let mut buffer: VecDeque<RelayMessage> = VecDeque::new();
|
||||
|
||||
// ── Step 1: Wait for AUTH challenge ───────────────────────────────────
|
||||
let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
|
||||
|
||||
// ── Step 2: Build and send kind:22242 auth event ──────────────────────
|
||||
send_auth_response(&mut ws, &challenge, relay_url, keys, auth_tag).await?;
|
||||
|
||||
// ── Step 3: Wait for OK ───────────────────────────────────────────────
|
||||
let event_id = {
|
||||
// We need the event_id that was just sent. Re-derive it by signing again
|
||||
// just to get the ID — but that's wasteful. Instead, parse the last sent
|
||||
@@ -2914,14 +2882,10 @@ async fn wait_for_any_ok(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── relay_ws_to_http ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relay_ws_to_http_plain() {
|
||||
assert_eq!(
|
||||
@@ -2962,8 +2926,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── channel_sub_id ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn channel_sub_id_format() {
|
||||
let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
@@ -2996,8 +2958,6 @@ mod tests {
|
||||
assert!(channel_id_from_sub_id("").is_none());
|
||||
}
|
||||
|
||||
// ── merge_discovered_channels (archived skip) ─────────────────────────────
|
||||
|
||||
fn meta_event(uuid: Uuid, name: &str, extra: &[&str]) -> serde_json::Value {
|
||||
let mut tags = vec![
|
||||
serde_json::json!(["d", uuid.to_string()]),
|
||||
@@ -3063,8 +3023,6 @@ mod tests {
|
||||
assert!(map.contains_key(&ch), "archived=false is treated as live");
|
||||
}
|
||||
|
||||
// ── parse_relay_message ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_ok_accepted() {
|
||||
let text = r#"["OK","abc123",true,""]"#;
|
||||
@@ -3227,8 +3185,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── channel_sub_id subscription format ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn subscription_id_starts_with_ch_prefix() {
|
||||
let uuid = Uuid::new_v4();
|
||||
@@ -3243,8 +3199,6 @@ mod tests {
|
||||
assert_eq!(sub_id, "ch-12345678-1234-5678-1234-567812345678");
|
||||
}
|
||||
|
||||
// ── BgState: seen_ids deduplication ──────────────────────────────────────
|
||||
|
||||
/// Build a real signed Nostr event for testing BgState.
|
||||
///
|
||||
/// Uses `custom_created_at` so tests can control the timestamp.
|
||||
@@ -3300,8 +3254,6 @@ mod tests {
|
||||
assert!(state.record_event(channel_id, &event2));
|
||||
}
|
||||
|
||||
// ── BgState: last_seen tracking ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bg_state_last_seen_set_on_first_event() {
|
||||
let mut state = BgState::new();
|
||||
@@ -3444,8 +3396,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bug 5: channel_dropped_since tracking ─────────────────────────────────
|
||||
|
||||
/// Test 8: channel_dropped_since records the OLDEST dropped timestamp.
|
||||
///
|
||||
/// Simulates the backpressure path directly on BgState:
|
||||
@@ -3564,8 +3514,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Membership dedup regression tests (M4) ───────────────────────────
|
||||
|
||||
/// Membership dedup must NOT contaminate per-channel `last_seen`.
|
||||
/// Using `record_event()` for membership notifications would update
|
||||
/// `last_seen[channel_uuid]`, causing channel resubscribe to use a
|
||||
@@ -3624,8 +3572,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── drop_channel_on_access_denied (Phenomenon A: reconnect loop) ──────────
|
||||
|
||||
/// Subscribe a channel via the production command path so the test exercises
|
||||
/// real subscription state (active_subscriptions + active_filters + since).
|
||||
fn subscribe_channel(state: &mut BgState, channel_id: Uuid) {
|
||||
|
||||
@@ -128,8 +128,6 @@ async fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Member management subcommands ─────────────────────────────────────────────
|
||||
|
||||
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
||||
if let Err(msg) = validate_role(&role) {
|
||||
eprintln!("error: {msg}");
|
||||
@@ -246,8 +244,6 @@ async fn cmd_list_members() -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
|
||||
fn validate_role(role: &str) -> std::result::Result<(), String> {
|
||||
match role {
|
||||
|
||||
@@ -419,7 +419,6 @@ fn openai_image_user_content(content: &[ToolResultContent]) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── OpenAI Responses API ───────────────────────────────────────────────────
|
||||
// Spec: https://platform.openai.com/docs/api-reference/responses
|
||||
//
|
||||
// Replay invariant: each assistant `function_call` input item **must**
|
||||
@@ -998,8 +997,6 @@ mod tests {
|
||||
assert_eq!(content[1]["source"]["data"], "aW1n");
|
||||
}
|
||||
|
||||
// ── Responses API unit tests ───────────────────────────────────────
|
||||
|
||||
fn cfg_responses() -> Config {
|
||||
let mut c = cfg(Provider::OpenAi);
|
||||
c.openai_api = OpenAiApi::Responses;
|
||||
@@ -1484,8 +1481,6 @@ mod tests {
|
||||
assert_eq!(sum_usage(&v, &["input_tokens", "prompt_tokens"]), None);
|
||||
}
|
||||
|
||||
// ── one-shot refresh-on-401 in post_openai ─────────────────────────
|
||||
|
||||
/// A token source whose `bearer()` always hands back the same stale
|
||||
/// token and whose `refresh_now()` mints a distinct fresh one, counting
|
||||
/// each refresh. Lets a test assert exactly how many forced refreshes a
|
||||
|
||||
@@ -345,7 +345,6 @@ async fn refresh_now_without_refresh_token_is_terminal() {
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ACP-level envelope regression test.
|
||||
//
|
||||
// Boots the real buzz-agent binary with `DATABRICKS_TOKEN` set (so the
|
||||
@@ -355,7 +354,6 @@ async fn refresh_now_without_refresh_token_is_terminal() {
|
||||
// is `Bearer <token>`, and the JSON body has *no* top-level `"model"`. This
|
||||
// locks in the DRY envelope behavior so a refactor of `post_openai` can't
|
||||
// silently break Databricks.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
|
||||
@@ -16,8 +16,6 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ─── Fake LLM server ────────────────────────────────────────────────────────
|
||||
|
||||
async fn spawn_fake_llm(responses: Vec<Value>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
@@ -59,8 +57,6 @@ async fn spawn_fake_llm(responses: Vec<Value>) -> String {
|
||||
url
|
||||
}
|
||||
|
||||
// ─── Request-capturing fake LLM server ──────────────────────────────────────
|
||||
|
||||
/// Like `spawn_fake_llm` but also captures the full JSON request body from each
|
||||
/// incoming HTTP request. Returns (url, captured_requests).
|
||||
async fn spawn_capturing_fake_llm(responses: Vec<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
@@ -144,8 +140,6 @@ async fn spawn_capturing_fake_llm(responses: Vec<Value>) -> (String, Arc<Mutex<V
|
||||
(url, captures)
|
||||
}
|
||||
|
||||
// ─── ACP harness ────────────────────────────────────────────────────────────
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
@@ -221,8 +215,6 @@ impl Harness {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Canned LLM responses (OpenAI-compat shape) ─────────────────────────────
|
||||
|
||||
fn openai_text(content: &str) -> Value {
|
||||
json!({
|
||||
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
|
||||
@@ -268,8 +260,6 @@ async fn init_session(h: &mut Harness) -> String {
|
||||
sid
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn text_only_end_turn() {
|
||||
let url = spawn_fake_llm(vec![openai_text("done")]).await;
|
||||
|
||||
@@ -12,8 +12,6 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ─── Fake LLM ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct CapturingLlm {
|
||||
url: String,
|
||||
captured: Arc<Mutex<Vec<Value>>>,
|
||||
@@ -83,8 +81,6 @@ async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
||||
CapturingLlm { url, captured }
|
||||
}
|
||||
|
||||
// ─── Harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
@@ -192,8 +188,6 @@ async fn init_session(h: &mut Harness, cwd: &str) -> String {
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AGENTS.md in cwd is loaded into the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_loaded_from_cwd_agents_md() {
|
||||
|
||||
@@ -15,8 +15,6 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ─── Fake LLM that captures requests so we can inspect history ──────────────
|
||||
|
||||
struct CapturingLlm {
|
||||
url: String,
|
||||
captured: Arc<Mutex<Vec<Value>>>,
|
||||
@@ -86,8 +84,6 @@ async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
||||
CapturingLlm { url, captured }
|
||||
}
|
||||
|
||||
// ─── Harness (minimal copy — keeping per-test independence) ─────────────────
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
@@ -237,8 +233,6 @@ async fn init_session(h: &mut Harness, mcp_servers: Value) -> String {
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// After a text-only assistant response, the next prompt's request must
|
||||
/// include that assistant text in `messages` history. Round 4 fix.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -524,8 +518,6 @@ fn openai_n_tool_calls(n: usize) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── New round-8 regression tests ──────────────────────────────────────────
|
||||
|
||||
/// History budget evicts old turns: after many prompts, the LLM request
|
||||
/// body stays below a sane bound. Round 7 fix; round 8 test.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -713,8 +705,6 @@ async fn description_clamping_enforced() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
// ─── Hook system regression tests ──────────────────────────────────────────
|
||||
|
||||
/// Helper: spawn a session with a fake MCP server exposing one regular tool
|
||||
/// plus an optional `_Stop` hook controlled by env vars.
|
||||
async fn init_session_with_fake_mcp(h: &mut Harness, extra_mcp_env: &[(&str, &str)]) -> String {
|
||||
|
||||
@@ -82,8 +82,6 @@ pub async fn check_write_access(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test-only mock ───────────────────────────────────────────────────────────
|
||||
|
||||
/// In-memory [`ChannelAccessChecker`] for unit tests.
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct MockAccessChecker {
|
||||
|
||||
@@ -7,10 +7,6 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blob / Media types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by the relay after a successful upload.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
@@ -78,10 +74,6 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
/// Maximum file size for video uploads (500 MB).
|
||||
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NIP-98 HTTP Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
|
||||
///
|
||||
/// The event includes:
|
||||
@@ -116,10 +108,6 @@ fn sign_nip98(
|
||||
Ok(format!("Nostr {}", B64.encode(json.as_bytes())))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuzzClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct BuzzClient {
|
||||
http: reqwest::Client,
|
||||
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place"
|
||||
@@ -213,10 +201,6 @@ impl BuzzClient {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTTP Bridge: POST /query
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Execute a one-shot query via the HTTP bridge.
|
||||
/// `filter` is a Nostr filter object (will be wrapped in an array).
|
||||
/// Returns the raw JSON response (array of events).
|
||||
@@ -261,10 +245,6 @@ impl BuzzClient {
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTTP Bridge: POST /events
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Submit a signed Nostr event via POST /events.
|
||||
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
|
||||
let url = format!("{}/events", self.relay_url);
|
||||
@@ -283,10 +263,6 @@ impl BuzzClient {
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebSocket publish (ephemeral events)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Publish an ephemeral event via WebSocket with NIP-42 authentication.
|
||||
///
|
||||
/// The relay rejects ephemeral kinds (20000–29999) over HTTP. Delegates to
|
||||
@@ -313,10 +289,6 @@ impl BuzzClient {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// File upload (Blossom protocol)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Upload a file to the relay's Blossom endpoint.
|
||||
/// Returns a BlobDescriptor on success.
|
||||
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
|
||||
@@ -423,10 +395,6 @@ impl BuzzClient {
|
||||
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Response handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async fn handle_response(&self, resp: reqwest::Response) -> Result<String, CliError> {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
@@ -458,10 +426,6 @@ impl BuzzClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash.
|
||||
/// BUZZ_RELAY_URL may be ws/wss (copied from MCP config).
|
||||
pub fn normalize_relay_url(url: &str) -> String {
|
||||
@@ -478,10 +442,6 @@ fn to_ws_url(http_url: &str) -> String {
|
||||
.replace("http://", "ws://")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output normalization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize raw event JSON array into consistent shape.
|
||||
/// Each event becomes: {id, pubkey, kind, content, created_at, tags}
|
||||
pub fn normalize_events(events: &[serde_json::Value]) -> String {
|
||||
|
||||
@@ -7,10 +7,6 @@ use crate::client::{
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read commands — POST /query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"channel_id": extract_d_tag(e),
|
||||
@@ -287,10 +283,6 @@ pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write commands — signed events via POST /events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_create_channel(
|
||||
client: &BuzzClient,
|
||||
name: &str,
|
||||
@@ -566,10 +558,6 @@ pub async fn cmd_set_canvas(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: crate::ChannelsCmd,
|
||||
client: &BuzzClient,
|
||||
|
||||
@@ -125,10 +125,6 @@ pub async fn cmd_add_dm_member(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::DmsCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -308,10 +308,6 @@ async fn cmd_import(
|
||||
publish_own_set(client, &final_set).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::EmojiCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -64,10 +64,6 @@ pub async fn cmd_get_feed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: crate::FeedCmd,
|
||||
client: &BuzzClient,
|
||||
|
||||
@@ -3,10 +3,6 @@ use crate::error::CliError;
|
||||
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
|
||||
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create issue — publish kind:1621
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_create_issue(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
@@ -37,10 +33,6 @@ pub async fn cmd_create_issue(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get issue — query kind:1621 by event id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> {
|
||||
validate_hex64(event)?;
|
||||
let filter = serde_json::json!({
|
||||
@@ -52,10 +44,6 @@ pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliEr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List issues — query kind:1621 by repo coordinate, with optional filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_list_issues(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
@@ -89,10 +77,6 @@ pub async fn cmd_list_issues(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status — publish kind:1630/1631/1632/1633 against an issue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_issue_status(
|
||||
client: &BuzzClient,
|
||||
@@ -160,10 +144,6 @@ pub async fn cmd_issue_status(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::IssuesCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -734,10 +734,6 @@ pub async fn cmd_rm(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::MemCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::MemCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -13,10 +13,6 @@ use buzz_sdk::mentions::{
|
||||
MENTION_CAP,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the thread root event ID from a Nostr tag array.
|
||||
///
|
||||
/// Parses `"e"` tags with NIP-10 markers:
|
||||
@@ -243,10 +239,6 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read commands — POST /query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
|
||||
match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
@@ -364,10 +356,6 @@ pub async fn cmd_search(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write commands — signed events via POST /events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct SendMessageParams {
|
||||
pub channel_id: String,
|
||||
pub content: String,
|
||||
@@ -634,10 +622,6 @@ pub async fn cmd_vote_on_post(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: crate::MessagesCmd,
|
||||
client: &BuzzClient,
|
||||
@@ -816,7 +800,6 @@ mod tests {
|
||||
assert!(find_root_from_tags(&json!(null)).is_none());
|
||||
}
|
||||
|
||||
// ── @mention resolution pipeline ────────────────────────────────────
|
||||
//
|
||||
// These tests don't hit the network — they prove that *given* the
|
||||
// events the relay returns, the CLI's parse + match wiring produces
|
||||
|
||||
@@ -41,10 +41,6 @@ pub const KIND_LONG_FORM: u16 = 30023;
|
||||
/// comfortably URL/filename-safe and matches `mem` slug ergonomics.
|
||||
pub const SLUG_MAX_LEN: usize = 80;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slug validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate and normalize a slug for use as a NIP-23 `d` tag.
|
||||
///
|
||||
/// Rules: 1..=80 chars, `[a-z0-9._-]` only. Lowercase ascii keeps memory
|
||||
@@ -72,10 +68,6 @@ pub fn parse_slug(raw: &str) -> Result<String, CliError> {
|
||||
Ok(raw.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NoteSnapshot — parsed view of a kind:30023 event, derived once
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parsed view of a NIP-23 long-form event. Built once via
|
||||
/// [`NoteSnapshot::from_event`] so the tag-parsing footgun lives in exactly
|
||||
/// one place; `set` (carry-forward), `get`/`ls` (output shaping), and the
|
||||
@@ -161,10 +153,6 @@ impl NoteSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_events(json: &str) -> Result<Vec<Event>, CliError> {
|
||||
serde_json::from_str::<Vec<Event>>(json)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse relay response: {e}")))
|
||||
@@ -206,10 +194,6 @@ pub async fn fetch_by_slug(client: &BuzzClient, slug: &str) -> Result<Vec<Event>
|
||||
parse_events(&raw)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Author resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve an `--author` flag value to a `PublicKey`.
|
||||
///
|
||||
/// Accepts:
|
||||
@@ -259,10 +243,6 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result<Pu
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate parsing (naddr / kind:pk:d / NIP-21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a `--naddr` flag. Accepts:
|
||||
/// - bech32 `naddr1…`
|
||||
/// - `<kind>:<pubkey-hex>:<d-tag>` (KPI format)
|
||||
@@ -293,10 +273,6 @@ pub fn coord_for(author: &PublicKey, slug: &str) -> nostr::nips::nip01::Coordina
|
||||
.identifier(slug.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate formatting (used when --name resolves to >1 author)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a list of candidate notes for the "ambiguous slug" error path.
|
||||
/// One line per candidate; sorted newest-first. Designed so the user can
|
||||
/// paste a pubkey into a follow-up `--author <hex>` invocation.
|
||||
@@ -320,10 +296,6 @@ pub fn format_note_candidates(snapshots: &[NoteSnapshot]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output helpers for `get` / `ls`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct NoteOutput {
|
||||
id: String,
|
||||
@@ -417,10 +389,6 @@ fn sort_snapshots_newest_first(snapshots: &mut [NoteSnapshot]) {
|
||||
snapshots.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event builder for `set` — pure, unit-testable carry-forward logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the unsigned `EventBuilder` for `notes set`. Pure function — no I/O,
|
||||
/// no clock — so every carry/clear/first-publish case is unit-testable.
|
||||
///
|
||||
@@ -516,10 +484,6 @@ fn now_secs() -> u64 {
|
||||
/// far above any realistic skill-KB note.
|
||||
pub const SET_STDIN_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch — stubs for verb implementations (filled by follow-up commits).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_set(
|
||||
client: &BuzzClient,
|
||||
slug: &str,
|
||||
|
||||
@@ -5,10 +5,6 @@ use crate::validate::{
|
||||
};
|
||||
use buzz_sdk::{GitAppliedPatchRef, GitPatchMeta, GitRepoCoord, GitStatus, GitStatusMeta};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send patch — publish kind:1617
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_send_patch(
|
||||
client: &BuzzClient,
|
||||
@@ -74,10 +70,6 @@ fn parse_committer(spec: &str) -> Result<(String, String, String, String), CliEr
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get patch — query kind:1617 by event id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_get_patch(client: &BuzzClient, event: &str) -> Result<(), CliError> {
|
||||
validate_hex64(event)?;
|
||||
let filter = serde_json::json!({
|
||||
@@ -89,10 +81,6 @@ pub async fn cmd_get_patch(client: &BuzzClient, event: &str) -> Result<(), CliEr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List patches — query kind:1617 by repo coordinate, with optional filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_list_patches(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
@@ -122,10 +110,6 @@ pub async fn cmd_list_patches(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status — publish kind:1630/1631/1632/1633 against a patch root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_patch_status(
|
||||
client: &BuzzClient,
|
||||
@@ -219,10 +203,6 @@ pub(crate) fn parse_status(s: &str) -> Result<GitStatus, CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::PatchesCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -124,10 +124,6 @@ pub async fn cmd_get_reactions(client: &BuzzClient, event_id: &str) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::ReactionsCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -2,10 +2,6 @@ use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::validate_repo_id;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create repo — publish kind:30617
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_create_repo(
|
||||
client: &BuzzClient,
|
||||
repo_id: &str,
|
||||
@@ -36,10 +32,6 @@ pub async fn cmd_create_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get repo — query kind:30617 by owner + d-tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_get_repo(
|
||||
client: &BuzzClient,
|
||||
repo_id: &str,
|
||||
@@ -64,10 +56,6 @@ pub async fn cmd_get_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List repos — query kind:30617 by author
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_list_repos(
|
||||
client: &BuzzClient,
|
||||
owner: Option<&str>,
|
||||
@@ -96,10 +84,6 @@ pub async fn cmd_list_repos(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::ReposCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -208,10 +208,6 @@ pub async fn cmd_get_list(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::SocialCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::SocialCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -290,10 +290,6 @@ pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), C
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: crate::UsersCmd,
|
||||
client: &BuzzClient,
|
||||
|
||||
@@ -9,10 +9,6 @@ use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
|
||||
|
||||
// TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read commands — POST /query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List workflows in a channel — query kind:30620 workflow definition events.
|
||||
pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
@@ -98,10 +94,6 @@ pub async fn cmd_get_workflow_runs(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write commands — signed events via POST /events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a workflow — sign and submit a kind:30620 event.
|
||||
pub async fn cmd_create_workflow(
|
||||
client: &BuzzClient,
|
||||
@@ -219,10 +211,6 @@ pub async fn cmd_approve_step(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::WorkflowsCmd;
|
||||
match cmd {
|
||||
|
||||
@@ -45,10 +45,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "buzz",
|
||||
@@ -87,10 +83,6 @@ struct Cli {
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value enums for typed --type / --visibility / --status flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, clap::ValueEnum)]
|
||||
pub enum ChannelType {
|
||||
#[value(name = "stream")]
|
||||
@@ -165,10 +157,6 @@ pub enum OutputFormat {
|
||||
Compact,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommand groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Send, read, search, and manage messages
|
||||
@@ -224,10 +212,6 @@ enum Cmd {
|
||||
Pack(PackCmd),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MessagesCmd {
|
||||
/// Send a message to a channel
|
||||
@@ -364,10 +348,6 @@ pub enum MessagesCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ChannelsCmd {
|
||||
/// List channels visible to the current identity
|
||||
@@ -536,10 +516,6 @@ pub enum ChannelsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum CanvasCmd {
|
||||
/// Get the canvas document for a channel
|
||||
@@ -559,10 +535,6 @@ pub enum CanvasCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactions subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ReactionsCmd {
|
||||
/// Add an emoji reaction to a message
|
||||
@@ -594,10 +566,6 @@ pub enum ReactionsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom emoji subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum EmojiCmd {
|
||||
/// List the workspace custom emoji palette (union of every member's set)
|
||||
@@ -640,10 +608,6 @@ pub enum EmojiCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DMs subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DmsCmd {
|
||||
/// List direct message conversations
|
||||
@@ -675,10 +639,6 @@ pub enum DmsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum UsersCmd {
|
||||
/// Look up user profiles by pubkey or name
|
||||
@@ -721,10 +681,6 @@ pub enum UsersCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflows subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum WorkflowsCmd {
|
||||
/// List workflows in a channel
|
||||
@@ -804,10 +760,6 @@ pub enum WorkflowsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feed subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum FeedCmd {
|
||||
/// Get recent activity feed entries
|
||||
@@ -824,10 +776,6 @@ pub enum FeedCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SocialCmd {
|
||||
/// Publish a text note (NIP-01 kind:1)
|
||||
@@ -905,10 +853,6 @@ pub enum SocialCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes subcommands (NIP-23 long-form)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum NotesCmd {
|
||||
/// Create or update a note. Idempotent upsert keyed by `(me, --name)`.
|
||||
@@ -988,10 +932,6 @@ pub enum NotesCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repos subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ReposCmd {
|
||||
/// Announce a git repository (NIP-34)
|
||||
@@ -1035,10 +975,6 @@ pub enum ReposCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patches subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PatchesCmd {
|
||||
/// Send a git patch (NIP-34 kind:1617)
|
||||
@@ -1145,10 +1081,6 @@ pub enum PatchesCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issues subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum IssuesCmd {
|
||||
/// Create a git issue (NIP-34 kind:1621)
|
||||
@@ -1224,10 +1156,6 @@ pub enum IssuesCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum UploadCmd {
|
||||
/// Upload a file to the relay's Blossom store
|
||||
@@ -1238,10 +1166,6 @@ pub enum UploadCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mem subcommands (NIP-AE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subcommands for `buzz mem`.
|
||||
#[derive(Subcommand)]
|
||||
pub enum MemCmd {
|
||||
@@ -1325,10 +1249,6 @@ pub enum MemCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pack subcommands (local, no relay connection needed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subcommands for `buzz pack`.
|
||||
#[derive(Subcommand)]
|
||||
pub enum PackCmd {
|
||||
@@ -1344,10 +1264,6 @@ pub enum PackCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
let relay_url = client::normalize_relay_url(&cli.relay);
|
||||
|
||||
@@ -1406,10 +1322,6 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -419,8 +419,6 @@ mod tests {
|
||||
assert!(super::parse_uuid("not-a-uuid").is_err());
|
||||
}
|
||||
|
||||
// ── validate_repo_id ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_repo_id_valid() {
|
||||
assert!(super::validate_repo_id("my-repo").is_ok());
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether a channel is publicly visible or invite-only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelVisibility {
|
||||
@@ -46,8 +44,6 @@ impl FromStr for ChannelVisibility {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel type ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The functional type of a channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelType {
|
||||
@@ -93,8 +89,6 @@ impl FromStr for ChannelType {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Member role ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A member's role within a channel.
|
||||
///
|
||||
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
|
||||
|
||||
@@ -153,8 +153,6 @@ pub fn d_tag(k_c: &ConversationKey, slug: &str) -> String {
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
// ── Bodies ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A decoded engram body. The slug discriminates the variant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Body {
|
||||
@@ -368,8 +366,6 @@ fn parse_strict_json(bytes: &[u8]) -> Result<serde_json::Value, EngramError> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
// ── Reference extraction (`[[slug]]`) ───────────────────────────────────────
|
||||
|
||||
/// Extract `[[slug]]` references from a body's free-form text field
|
||||
/// (`profile` for [`Body::Core`], `value` for [`Body::Memory`]).
|
||||
///
|
||||
@@ -431,8 +427,6 @@ pub fn extract_refs(body: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
// ── Envelope build / parse ──────────────────────────────────────────────────
|
||||
|
||||
/// Build a signed `kind:30174` event for a given body.
|
||||
///
|
||||
/// * `created_at` is the timestamp to sign — callers MUST supply a value
|
||||
@@ -618,9 +612,7 @@ mod tests {
|
||||
Keys::parse(s).unwrap()
|
||||
}
|
||||
|
||||
// ── Reference test vectors from docs/nips/NIP-AE.md §"Reference test
|
||||
// vectors". Pinning these as CI invariants is the single best
|
||||
// interop guarantee for this implementation. ──
|
||||
|
||||
const SECKEY_A: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const SECKEY_O: &str = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
@@ -895,8 +887,6 @@ mod tests {
|
||||
assert!(matches!(err, EngramError::BodyTooLarge(_)));
|
||||
}
|
||||
|
||||
// ── extract_refs ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_refs_empty_body() {
|
||||
assert!(extract_refs("").is_empty());
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
use crate::channel::MemberRole;
|
||||
use std::fmt;
|
||||
|
||||
// ── Limits (DoS prevention for untrusted kind:30617 input) ───────────────────
|
||||
|
||||
/// Maximum number of `buzz-protect` tags per repo.
|
||||
pub const MAX_PROTECTION_RULES: usize = 50;
|
||||
/// Maximum character length of a ref pattern.
|
||||
@@ -24,8 +22,6 @@ pub const MAX_PATTERN_LENGTH: usize = 256;
|
||||
/// Maximum number of wildcard segments per pattern.
|
||||
pub const MAX_WILDCARDS_PER_PATTERN: usize = 3;
|
||||
|
||||
// ── Ref Pattern ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A validated ref pattern for matching git refs.
|
||||
///
|
||||
/// Grammar: `segment ("/" segment)*` where segment is either a literal
|
||||
@@ -195,8 +191,6 @@ impl fmt::Display for RefPattern {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update Classification ────────────────────────────────────────────────────
|
||||
|
||||
/// The type of ref update in a push.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateKind {
|
||||
@@ -242,8 +236,6 @@ pub struct RefUpdate {
|
||||
pub new_oid: String,
|
||||
}
|
||||
|
||||
// ── Protection Rules ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A single protection rule parsed from a `buzz-protect` tag on kind:30617.
|
||||
///
|
||||
/// Format: `["buzz-protect", "<ref-pattern>", "<rule>", ...]`
|
||||
@@ -407,8 +399,6 @@ pub fn parse_protection_tags(tags: &[Vec<String>]) -> Result<ParsedProtection, R
|
||||
})
|
||||
}
|
||||
|
||||
// ── Built-in Defaults ────────────────────────────────────────────────────────
|
||||
|
||||
/// Built-in default minimum role for an operation when no `buzz-protect` tag matches.
|
||||
pub fn default_min_role(ref_name: &str, kind: UpdateKind) -> MemberRole {
|
||||
let is_branch = ref_name.starts_with("refs/heads/");
|
||||
@@ -437,8 +427,6 @@ pub fn default_min_role(ref_name: &str, kind: UpdateKind) -> MemberRole {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Effective Rules (union of all matching patterns) ─────────────────────────
|
||||
|
||||
/// The effective constraints for a ref after unioning all matching rules.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EffectiveRules {
|
||||
@@ -499,8 +487,6 @@ impl EffectiveRules {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Policy Denial ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single denial reason from the policy engine.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Denial {
|
||||
@@ -612,14 +598,10 @@ pub fn evaluate_push(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── RefPattern tests ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pattern_parse_valid() {
|
||||
let p = RefPattern::parse("refs/heads/main").unwrap();
|
||||
@@ -697,8 +679,6 @@ mod tests {
|
||||
assert!(!p.matches("refs/heads"));
|
||||
}
|
||||
|
||||
// ── UpdateKind tests ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_create() {
|
||||
let zero = "0000000000000000000000000000000000000000";
|
||||
@@ -733,8 +713,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Protection rule parsing ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_protection_tag_basic() {
|
||||
let rule =
|
||||
@@ -791,8 +769,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Effective rules (union semantics) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn effective_rules_union_strictest_role() {
|
||||
let rules = vec![
|
||||
@@ -812,8 +788,6 @@ mod tests {
|
||||
assert!(!eff.has_explicit_match);
|
||||
}
|
||||
|
||||
// ── Policy evaluation ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn evaluate_owner_passes_push_role() {
|
||||
let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()];
|
||||
|
||||
@@ -375,7 +375,6 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
|
||||
/// Internal kind for media upload audit entries. Not a relay event kind.
|
||||
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
|
||||
|
||||
// ── NIP-34: Git repository events ────────────────────────────────────────────
|
||||
/// NIP-34: Repository announcement (parameterized replaceable, d-tag = repo-id).
|
||||
pub const KIND_GIT_REPO_ANNOUNCEMENT: u32 = 30617;
|
||||
/// NIP-34: Repository state — current branch/tag refs (parameterized replaceable, d-tag = repo-id).
|
||||
|
||||
@@ -23,14 +23,10 @@
|
||||
use nostr::hashes::Hash as _;
|
||||
use nostr::util::hkdf;
|
||||
|
||||
// ── HKDF info strings ────────────────────────────────────────────────────────
|
||||
|
||||
const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id";
|
||||
const INFO_SAS: &[u8] = b"nostr-pair-sas-v1";
|
||||
const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1";
|
||||
|
||||
// ── Internal helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Run HKDF-SHA256(IKM=`ikm`, salt=`salt`, info=`info`) and return 32 bytes.
|
||||
///
|
||||
/// Uses `nostr::util::hkdf::{extract, expand}` directly so we don't pull in
|
||||
@@ -46,8 +42,6 @@ fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||
out
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Derive the session ID from the session secret.
|
||||
///
|
||||
/// ```text
|
||||
@@ -134,14 +128,10 @@ pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
||||
a.ct_eq(b).into()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Test vector inputs (from NIP-AB spec) ─────────────────────────────────
|
||||
|
||||
/// session_secret = 0xa1b2c3d4…
|
||||
fn session_secret() -> [u8; 32] {
|
||||
hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")
|
||||
@@ -166,8 +156,6 @@ mod tests {
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
// ── session_id derivation ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn session_id_is_deterministic() {
|
||||
let secret = session_secret();
|
||||
@@ -199,8 +187,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── SAS derivation ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sas_code_is_six_digits() {
|
||||
// Use a synthetic ECDH shared secret (just some fixed bytes).
|
||||
@@ -257,8 +243,6 @@ mod tests {
|
||||
assert!(code < 1_000_000);
|
||||
}
|
||||
|
||||
// ── transcript_hash derivation ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn transcript_hash_is_deterministic() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
@@ -365,8 +349,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── format_sas ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_sas_zero_padding() {
|
||||
assert_eq!(format_sas(0), "000000");
|
||||
@@ -385,8 +367,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full round-trip consistency ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn full_derivation_round_trip() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
@@ -29,8 +29,6 @@ use zeroize::Zeroize;
|
||||
|
||||
use super::PairingError;
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Data encoded in the QR code displayed by the source device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QrPayload {
|
||||
@@ -57,8 +55,6 @@ impl Drop for QrPayload {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encoding ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Encode a [`QrPayload`] as a `nostrpair://` URI.
|
||||
///
|
||||
/// Relay URLs are percent-encoded (`:` → `%3A`, `/` → `%2F`) so they can
|
||||
@@ -96,8 +92,6 @@ pub fn encode_qr(payload: &QrPayload) -> String {
|
||||
uri
|
||||
}
|
||||
|
||||
// ── Decoding ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode a `nostrpair://` URI into a [`QrPayload`].
|
||||
///
|
||||
/// # Errors
|
||||
@@ -225,8 +219,6 @@ pub fn decode_qr(uri: &str) -> Result<QrPayload, PairingError> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── URL encoding helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Percent-encode a relay URL for use as a query parameter value.
|
||||
///
|
||||
/// Uses `percent-encoding` crate's `NON_ALPHANUMERIC` set, which encodes
|
||||
@@ -250,8 +242,6 @@ fn is_lowercase_hex(c: char) -> bool {
|
||||
c.is_ascii_digit() || ('a'..='f').contains(&c)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -544,8 +534,6 @@ mod tests {
|
||||
assert_eq!(decoded.version, 1, "missing v= should default to version 1");
|
||||
}
|
||||
|
||||
// ── All-zeros session_secret rejection ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reject_all_zeros_session_secret() {
|
||||
let keys = Keys::generate();
|
||||
@@ -563,8 +551,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lowercase hex enforcement ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reject_uppercase_hex_in_pubkey() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
@@ -45,8 +45,6 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// NIP-AB event kind (from the kind registry).
|
||||
const PAIRING_KIND: u16 = crate::kind::KIND_PAIRING as u16;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which role this device plays in the pairing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
@@ -108,8 +106,6 @@ pub struct PairingSession {
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
// ── Source-side constructors and methods ───────────────────────────────────────
|
||||
|
||||
impl PairingSession {
|
||||
/// Create a new source session. Returns the session and a QR payload
|
||||
/// to display to the user.
|
||||
@@ -283,8 +279,6 @@ impl PairingSession {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Target-side constructors and methods ──────────────────────────────────────
|
||||
|
||||
impl PairingSession {
|
||||
/// Create a new target session from a scanned QR payload.
|
||||
///
|
||||
@@ -427,8 +421,6 @@ impl PairingSession {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared methods ────────────────────────────────────────────────────────────
|
||||
|
||||
impl PairingSession {
|
||||
/// Build an abort event. Returns `None` if no peer is known yet
|
||||
/// (nothing to encrypt to), but still transitions to [`SessionState::Aborted`].
|
||||
@@ -535,8 +527,6 @@ impl PairingSession {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test-only accessors ───────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
impl PairingSession {
|
||||
/// Returns `true` if the given event ID has been recorded as processed.
|
||||
@@ -553,8 +543,6 @@ impl PairingSession {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
impl PairingSession {
|
||||
/// Encrypt a message and wrap it in a signed kind:24134 event.
|
||||
///
|
||||
@@ -765,8 +753,6 @@ fn unexpected(expected: &str, got: &PairingMessage) -> PairingError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -900,8 +900,6 @@ fn row_to_member_record(row: sqlx::postgres::PgRow) -> Result<MemberRecord> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 2: Channel Metadata ─────────────────────────────────────────────────
|
||||
|
||||
/// Partial update for channel metadata. Every field is `None` to leave the
|
||||
/// column unchanged.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -869,7 +869,6 @@ pub async fn insert_event_with_thread_metadata(
|
||||
let not_before = extract_not_before(event);
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// ── Insert event ──────────────────────────────────────────────────────────
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before)
|
||||
@@ -893,7 +892,6 @@ pub async fn insert_event_with_thread_metadata(
|
||||
|
||||
let was_inserted = result.rows_affected() > 0;
|
||||
|
||||
// ── Insert thread metadata (if provided and event was actually inserted) ──
|
||||
if was_inserted {
|
||||
if let Some(ref meta) = thread_meta {
|
||||
let broadcast_val: bool = meta.broadcast;
|
||||
|
||||
@@ -216,8 +216,6 @@ impl Db {
|
||||
self.pool.begin().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate.
|
||||
pub async fn insert_event(
|
||||
&self,
|
||||
@@ -339,8 +337,6 @@ impl Db {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
|
||||
pub async fn create_channel(
|
||||
&self,
|
||||
@@ -539,8 +535,6 @@ impl Db {
|
||||
channel::reap_expired_ephemeral_channels(&self.pool).await
|
||||
}
|
||||
|
||||
// ── Reminder scheduler ───────────────────────────────────────────────────
|
||||
|
||||
/// Query due reminders ready for delivery.
|
||||
pub async fn query_due_reminders(
|
||||
&self,
|
||||
@@ -559,8 +553,6 @@ impl Db {
|
||||
event::claim_due_reminder(&self.pool, event_id, event_created_at).await
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ensure a user record exists (upsert).
|
||||
pub async fn ensure_user(&self, pubkey: &[u8]) -> Result<()> {
|
||||
user::ensure_user(&self.pool, pubkey).await
|
||||
@@ -633,8 +625,6 @@ impl Db {
|
||||
user::set_channel_add_policy(&self.pool, pubkey, policy).await
|
||||
}
|
||||
|
||||
// ── Direct Messages ──────────────────────────────────────────────────────
|
||||
|
||||
/// Find an existing DM by its participant hash.
|
||||
pub async fn find_dm_by_participants(
|
||||
&self,
|
||||
@@ -689,8 +679,6 @@ impl Db {
|
||||
dm::list_hidden_dms(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
// ── Threads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Insert thread metadata.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn insert_thread_metadata(
|
||||
@@ -776,8 +764,6 @@ impl Db {
|
||||
thread::decrement_reply_count(&self.pool, parent_event_id, root_event_id).await
|
||||
}
|
||||
|
||||
// ── Reactions ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Add (or re-activate) a reaction.
|
||||
pub async fn add_reaction(
|
||||
&self,
|
||||
@@ -868,8 +854,6 @@ impl Db {
|
||||
reaction::get_reactions_bulk(&self.pool, event_ids).await
|
||||
}
|
||||
|
||||
// ── Feed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Find events that @mention the given pubkey.
|
||||
pub async fn query_feed_mentions(
|
||||
&self,
|
||||
@@ -962,8 +946,6 @@ impl Db {
|
||||
feed::query_activity(&self.pool, accessible_channel_ids, since, limit).await
|
||||
}
|
||||
|
||||
// ── API Tokens ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a new API token record.
|
||||
pub async fn create_api_token(
|
||||
&self,
|
||||
@@ -1103,8 +1085,6 @@ impl Db {
|
||||
api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await
|
||||
}
|
||||
|
||||
// ── Workflows ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a new workflow.
|
||||
pub async fn create_workflow(
|
||||
&self,
|
||||
@@ -1286,8 +1266,6 @@ impl Db {
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Partitions ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Ensures monthly partitions exist for the next N months.
|
||||
pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> {
|
||||
partition::ensure_future_partitions(&self.pool, months_ahead).await
|
||||
@@ -1312,8 +1290,6 @@ impl Db {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
// ── Pubkey Allowlist ─────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a pubkey is in the allowlist.
|
||||
pub async fn is_pubkey_allowed(&self, pubkey: &[u8]) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE pubkey = $1")
|
||||
@@ -1381,8 +1357,6 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Relay Members (NIP-43) ───────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
|
||||
relay_members::is_relay_member(&self.pool, pubkey).await
|
||||
@@ -1450,8 +1424,6 @@ impl Db {
|
||||
relay_members::backfill_from_allowlist(&self.pool).await
|
||||
}
|
||||
|
||||
// ── Archived identities (NIP-IA) ──────────────────────────────────────────
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is currently archived.
|
||||
pub async fn is_archived(&self, pubkey: &str) -> Result<bool> {
|
||||
archived_identities::is_archived(&self.pool, pubkey).await
|
||||
@@ -1489,8 +1461,6 @@ impl Db {
|
||||
archived_identities::list_archived(&self.pool).await
|
||||
}
|
||||
|
||||
// ── Discovery events ─────────────────────────────────────────────────────
|
||||
|
||||
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
|
||||
pub async fn soft_delete_discovery_events(
|
||||
&self,
|
||||
@@ -1508,8 +1478,6 @@ impl Db {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
// ── Replaceable events ─────────────────────────────────────────────────
|
||||
|
||||
/// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999)
|
||||
/// and NIP-29 discovery state (39000–39002, called from side_effects.rs).
|
||||
///
|
||||
|
||||
@@ -555,8 +555,6 @@ mod tests {
|
||||
assert!(result.is_err(), "should reject invalid policy value");
|
||||
}
|
||||
|
||||
// ── LIKE escaping unit tests (no DB required) ──────────────────────
|
||||
|
||||
// Use the production `escape_like` function directly — no local mirror.
|
||||
use super::escape_like;
|
||||
|
||||
|
||||
@@ -554,10 +554,6 @@ fn human_bytes(n: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -55,8 +55,6 @@ use tokio_tungstenite::tungstenite::protocol::{Message, Role, WebSocketConfig};
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Hard per-connection lifetime. `pub(crate)` for test access.
|
||||
pub(crate) const CONN_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
@@ -90,8 +88,6 @@ const ENTRY_TTL: Duration = Duration::from_secs(300);
|
||||
/// Freshness window in seconds (±).
|
||||
const FRESHNESS_SECS: i64 = 120;
|
||||
|
||||
// ── Core types ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum OutMsg {
|
||||
Text(String),
|
||||
Pong(Vec<u8>),
|
||||
@@ -220,8 +216,6 @@ impl Relay {
|
||||
}
|
||||
}
|
||||
|
||||
// ── RAII connection guard ─────────────────────────────────────────────────────
|
||||
|
||||
struct ConnGuard {
|
||||
relay: Arc<Relay>,
|
||||
conn_id: u64,
|
||||
@@ -239,8 +233,6 @@ impl Drop for ConnGuard {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct RateWindow {
|
||||
count: u32,
|
||||
window_start: tokio::time::Instant,
|
||||
@@ -264,8 +256,6 @@ impl RateWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn jarr(v: Vec<Value>) -> String {
|
||||
Value::Array(v).to_string()
|
||||
}
|
||||
@@ -301,8 +291,6 @@ fn make_notice(msg: &str) -> String {
|
||||
])
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_lower_hex(s: &str, len: usize) -> bool {
|
||||
s.len() == len && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
@@ -604,8 +592,6 @@ fn decode_hex64(s: &str) -> Option<[u8; 64]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ── Writer task ───────────────────────────────────────────────────────────────
|
||||
|
||||
type WsSink = futures_util::stream::SplitSink<WebSocketStream<TokioIo<Upgraded>>, Message>;
|
||||
|
||||
async fn writer_task(mut sink: WsSink, mut rx: mpsc::Receiver<OutMsg>, cancel: CancellationToken) {
|
||||
@@ -631,8 +617,6 @@ async fn writer_task(mut sink: WsSink, mut rx: mpsc::Receiver<OutMsg>, cancel: C
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection handler ────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_conn(relay: Arc<Relay>, conn_id: u64, stream: WebSocketStream<TokioIo<Upgraded>>) {
|
||||
let _guard = ConnGuard {
|
||||
relay: Arc::clone(&relay),
|
||||
@@ -926,8 +910,6 @@ async fn handle_conn(relay: Arc<Relay>, conn_id: u64, stream: WebSocketStream<To
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// ── HTTP upgrade ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn http_service(
|
||||
relay: Arc<Relay>,
|
||||
mut req: Request<Incoming>,
|
||||
@@ -1012,8 +994,6 @@ async fn http_service(
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ── Server loop (extracted for testability) ───────────────────────────────────
|
||||
|
||||
/// Run the relay accept loop on the given listener.
|
||||
/// Public for integration tests that bind to `:0`.
|
||||
pub async fn run_server(listener: TcpListener, relay: Arc<Relay>) {
|
||||
|
||||
@@ -13,13 +13,9 @@ use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, Web
|
||||
|
||||
use buzz_pair_relay::{run_server, Relay};
|
||||
|
||||
// ── Crypto imports (for real event signing) ───────────────────────────────────
|
||||
|
||||
use secp256k1::{Keypair, Secp256k1, SecretKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A valid 64-char lowercase hex string (all 'a's).
|
||||
const P_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
/// A different valid 64-char lowercase hex string (all 'b's).
|
||||
@@ -32,8 +28,6 @@ const PUBKEY: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddd
|
||||
/// A valid sig (128 'e's) — used only in pre-sig-check rejection tests.
|
||||
const SIG: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
|
||||
|
||||
// ── Test infrastructure ───────────────────────────────────────────────────────
|
||||
|
||||
type WS = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Start a relay on a random port, return the WebSocket URL.
|
||||
@@ -92,8 +86,6 @@ async fn assert_closed(ws: &mut WS) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a random keypair; returns `(SecretKey, pubkey_hex)`.
|
||||
fn gen_keypair() -> (SecretKey, String) {
|
||||
let secp = Secp256k1::new();
|
||||
@@ -206,8 +198,6 @@ async fn subscribe(ws: &mut WS, sub_id: &str, p_hex: &str) {
|
||||
assert_eq!(eose[1], sub_id);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 1. No replay: events published before a subscription are not delivered.
|
||||
/// With tightening #2, publishing with no live subscriber is rejected
|
||||
/// ("no live subscriber"), so the publisher gets OK false.
|
||||
|
||||
@@ -30,8 +30,6 @@ use tokio::time::timeout;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
// ── CLI definition ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "buzz-pair",
|
||||
@@ -72,8 +70,6 @@ enum Cmd {
|
||||
TestVectors,
|
||||
}
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum CliError {
|
||||
#[error("pairing error: {0}")]
|
||||
@@ -98,8 +94,6 @@ enum CliError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
@@ -117,8 +111,6 @@ async fn run(cmd: Cmd) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── source subcommand ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliError> {
|
||||
// Resolve the payload to transfer.
|
||||
let (payload_str, payload_type) = resolve_payload(nsec)?;
|
||||
@@ -208,8 +200,6 @@ async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliEr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── target subcommand ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result<(), CliError> {
|
||||
// Read QR URI from stdin.
|
||||
print!("Paste the QR URI: ");
|
||||
@@ -342,8 +332,6 @@ async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── test-vectors subcommand ───────────────────────────────────────────────────
|
||||
|
||||
fn cmd_test_vectors() -> Result<(), CliError> {
|
||||
// Fixed test keys from the NIP-AB spec.
|
||||
let session_secret: [u8; 32] =
|
||||
@@ -409,8 +397,6 @@ fn cmd_test_vectors() -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Abort-aware event helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// Check whether `event` is an abort from the peer. If so, transition the
|
||||
/// session and return an error the caller can propagate. Otherwise return
|
||||
/// `Ok(())` so the caller can proceed with its own handler.
|
||||
@@ -423,8 +409,6 @@ fn check_for_abort(session: &mut PairingSession, event: &Event) -> Result<(), Cl
|
||||
}
|
||||
}
|
||||
|
||||
// ── NIP-42 auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
/// Handle NIP-42 authentication if the relay requires it.
|
||||
///
|
||||
/// Uses the pairing session's ephemeral keys to authenticate, ensuring the
|
||||
@@ -502,8 +486,6 @@ fn parse_auth_challenge(text: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── WebSocket helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Publish a Nostr event to the relay.
|
||||
async fn publish_event<S>(write: &mut S, event: &Event) -> Result<(), CliError>
|
||||
where
|
||||
@@ -592,8 +574,6 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option<Event> {
|
||||
serde_json::from_value(arr[2].clone()).ok()
|
||||
}
|
||||
|
||||
// ── Payload helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the payload to send.
|
||||
///
|
||||
/// If `nsec` is provided, parse it as bech32 and return the raw nsec string.
|
||||
@@ -617,8 +597,6 @@ fn resolve_payload(nsec: Option<String>) -> Result<(Zeroizing<String>, PayloadTy
|
||||
}
|
||||
}
|
||||
|
||||
// ── I/O helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a single line from stdin (trims trailing newline).
|
||||
fn read_line() -> Result<String, CliError> {
|
||||
let stdin = io::stdin();
|
||||
@@ -636,8 +614,6 @@ fn read_yes_no() -> Result<bool, CliError> {
|
||||
Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
|
||||
}
|
||||
|
||||
// ── Crypto helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode a 64-char hex string into a `[u8; 32]`.
|
||||
fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> {
|
||||
let bytes = hex::decode(s).map_err(|e| CliError::Other(format!("invalid hex '{s}': {e}")))?;
|
||||
|
||||
@@ -19,8 +19,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::persona::RespondTo;
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("failed to read file: {0}")]
|
||||
@@ -33,8 +31,6 @@ pub enum ManifestError {
|
||||
MissingField(String),
|
||||
}
|
||||
|
||||
// ── Supporting types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Semver engine constraints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -73,8 +69,6 @@ pub struct BehavioralDefaults {
|
||||
pub broadcast_replies: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Core struct ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The pack manifest from `.plugin/plugin.json`.
|
||||
///
|
||||
/// OPS required fields (`id`, `name`, `version`) are validated after
|
||||
@@ -83,12 +77,10 @@ pub struct BehavioralDefaults {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PackManifest {
|
||||
// ── OPS required ──────────────────────────────────────────────────────
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
|
||||
// ── OPS optional ──────────────────────────────────────────────────────
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
@@ -107,7 +99,6 @@ pub struct PackManifest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engines: Option<Engines>,
|
||||
|
||||
// ── Buzz extensions ─────────────────────────────────────────────────
|
||||
/// Paths to `.persona.md` files (pack-relative).
|
||||
#[serde(default)]
|
||||
pub personas: Vec<String>,
|
||||
@@ -129,8 +120,6 @@ pub struct PackManifest {
|
||||
pub defaults: Option<BehavioralDefaults>,
|
||||
}
|
||||
|
||||
// ── Intermediate for post-parse validation ────────────────────────────────────
|
||||
|
||||
/// Mirrors `PackManifest` but with required fields as `Option` so we can
|
||||
/// produce a clean `MissingField` error instead of a serde path error.
|
||||
///
|
||||
@@ -159,8 +148,6 @@ struct RawManifest {
|
||||
defaults: Option<BehavioralDefaults>,
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a `plugin.json` string into a [`PackManifest`].
|
||||
pub fn parse_manifest(content: &str) -> Result<PackManifest, ManifestError> {
|
||||
let raw: RawManifest = serde_json::from_str(content)?;
|
||||
@@ -205,20 +192,14 @@ pub fn parse_manifest_file(path: &Path) -> Result<PackManifest, ManifestError> {
|
||||
parse_manifest(&content)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn minimal_json() -> &'static str {
|
||||
r#"{"id":"my-pack","name":"My Pack","version":"1.0.0","personas":["personas/bot.persona.md"]}"#
|
||||
}
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_valid() {
|
||||
let m = parse_manifest(minimal_json()).unwrap();
|
||||
@@ -306,8 +287,6 @@ mod tests {
|
||||
assert_eq!(rt.keywords, vec!["hey"]);
|
||||
}
|
||||
|
||||
// ── Missing required fields ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn missing_id_errors() {
|
||||
let json = r#"{"name":"P","version":"1.0.0"}"#;
|
||||
@@ -338,8 +317,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty required fields ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_id_errors() {
|
||||
let json = r#"{"id":"","name":"P","version":"1.0.0"}"#;
|
||||
@@ -380,8 +357,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Malformed JSON ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn malformed_json_errors() {
|
||||
let err = parse_manifest("{not valid json}").unwrap_err();
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
///
|
||||
/// Levels 1–2 (operator env vars, desktop UI) are resolved at runtime.
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TriggersData {
|
||||
pub mentions: bool,
|
||||
@@ -37,13 +35,9 @@ pub struct ResolvedConfig {
|
||||
pub broadcast_replies: bool,
|
||||
}
|
||||
|
||||
// ── Built-in defaults ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_THREAD_REPLIES: bool = true;
|
||||
const DEFAULT_BROADCAST_REPLIES: bool = false;
|
||||
|
||||
// ── Core merge ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Merge pack defaults with per-persona values.
|
||||
///
|
||||
/// Rules:
|
||||
@@ -87,8 +81,6 @@ pub fn merge_behavioral_config(
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
// ── High-level resolver ───────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a single persona's effective config from raw frontmatter + pack defaults.
|
||||
pub fn resolve_persona_config(
|
||||
persona_frontmatter: &serde_json::Value,
|
||||
@@ -178,8 +170,6 @@ pub fn resolve_persona_config(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn string_field(v: &serde_json::Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(|v| v.as_str()).map(str::to_owned)
|
||||
}
|
||||
@@ -207,15 +197,11 @@ fn parse_triggers(v: &serde_json::Value) -> Option<TriggersData> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── merge_behavioral_config ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_value_wins_over_pack_default() {
|
||||
let persona = json!({ "model": "gpt-4o", "thread_replies": false });
|
||||
@@ -287,8 +273,6 @@ mod tests {
|
||||
assert_eq!(merged["subscribe"], json!(["chan-x"])); // persona wins
|
||||
}
|
||||
|
||||
// ── resolve_persona_config ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn built_in_defaults_when_no_fields() {
|
||||
let persona = json!({});
|
||||
@@ -340,8 +324,6 @@ mod tests {
|
||||
assert_eq!(resolved.max_context_tokens, Some(8192));
|
||||
}
|
||||
|
||||
// ── triggers shallow replacement ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn triggers_shallow_replacement() {
|
||||
// Persona sets `mentions: false` — entire triggers replaces pack default.
|
||||
@@ -439,8 +421,6 @@ mod tests {
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
// ── subscribe merge (Option<Vec<String>>) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn subscribe_null_falls_through() {
|
||||
// persona subscribe: null → falls through to pack default.
|
||||
|
||||
@@ -22,8 +22,6 @@ use crate::manifest::{self, ManifestError};
|
||||
use crate::merge::{resolve_persona_config, HooksData, TriggersData};
|
||||
use crate::persona::{self, PersonaConfig};
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PackError {
|
||||
#[error("manifest not found at {0}")]
|
||||
@@ -61,8 +59,6 @@ impl From<ManifestError> for PackError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A fully loaded persona pack.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedPack {
|
||||
@@ -118,8 +114,6 @@ pub struct PackManifestData {
|
||||
pub defaults: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Load a persona pack from a directory.
|
||||
///
|
||||
/// 1. Read and parse `.plugin/plugin.json`
|
||||
@@ -246,8 +240,6 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Skill resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Determine which skills go to which persona.
|
||||
///
|
||||
/// - Skills listed in a persona's `skills:` array → only that persona
|
||||
@@ -322,8 +314,6 @@ pub fn resolve_skills(pack_dir: &Path, personas: &[LoadedPersona]) -> HashMap<St
|
||||
result
|
||||
}
|
||||
|
||||
// ── Path safety ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Verify a path resolves within the pack root.
|
||||
///
|
||||
/// Defense-in-depth:
|
||||
@@ -373,8 +363,6 @@ fn safe_resolve(pack_root: &Path, relative: &str) -> Result<PathBuf, PackError>
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
// ── Parsing helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn read_file(path: &Path) -> Result<String, PackError> {
|
||||
std::fs::read_to_string(path).map_err(|e| PackError::Io {
|
||||
path: path.to_path_buf(),
|
||||
@@ -456,16 +444,12 @@ fn parse_persona_file(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Fixture helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn make_pack(dir: &TempDir, personas: &[(&str, &str)]) -> PathBuf {
|
||||
let root = dir.path();
|
||||
|
||||
@@ -504,8 +488,6 @@ description: A fast worker
|
||||
You are Berry, a fast and direct worker.
|
||||
"#;
|
||||
|
||||
// ── load_pack: happy path ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_valid_pack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -548,8 +530,6 @@ You are Berry, a fast and direct worker.
|
||||
assert!(pack.skills_dir.is_some());
|
||||
}
|
||||
|
||||
// ── load_pack: error cases ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn missing_plugin_json_returns_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -578,8 +558,6 @@ You are Berry, a fast and direct worker.
|
||||
assert!(matches!(err, PackError::PersonaNotFound(_)));
|
||||
}
|
||||
|
||||
// ── Path safety ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dotdot_component_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -627,8 +605,6 @@ You are Berry, a fast and direct worker.
|
||||
assert!(matches!(err, PackError::PathEscape(_)));
|
||||
}
|
||||
|
||||
// ── Skill resolution ──────────────────────────────────────────────────────
|
||||
|
||||
fn make_loaded_persona(name: &str, skills: Vec<&str>) -> LoadedPersona {
|
||||
LoadedPersona {
|
||||
source_path: PathBuf::from(format!("{name}.persona.md")),
|
||||
@@ -720,8 +696,6 @@ You are Berry, a fast and direct worker.
|
||||
assert!(!map["alpha"].iter().any(|s| s.contains('/')));
|
||||
}
|
||||
|
||||
// ── Pack defaults ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pack_defaults_applied_to_persona() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -17,16 +17,12 @@ use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Safety limits ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum YAML frontmatter size in bytes (1 MiB).
|
||||
pub const MAX_FRONTMATTER_BYTES: usize = 1_048_576;
|
||||
|
||||
/// Maximum persona prompt (markdown body) size in bytes (256 KiB).
|
||||
pub const MAX_BODY_BYTES: usize = 262_144;
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PersonaError {
|
||||
#[error("failed to read file: {0}")]
|
||||
@@ -51,8 +47,6 @@ pub enum PersonaError {
|
||||
MissingField(String),
|
||||
}
|
||||
|
||||
// ── Supporting types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Controls which messages trigger a response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -98,8 +92,6 @@ pub struct Hooks {
|
||||
pub on_message: Option<String>,
|
||||
}
|
||||
|
||||
// ── Core struct ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Typed representation of a `.persona.md` file (V7 spec).
|
||||
///
|
||||
/// The `prompt` field holds the markdown body (system prompt).
|
||||
@@ -107,7 +99,6 @@ pub struct Hooks {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PersonaConfig {
|
||||
// ── Identity ──────────────────────────────────────────────────────────
|
||||
/// Machine name (slug). Required.
|
||||
pub name: String,
|
||||
|
||||
@@ -121,14 +112,12 @@ pub struct PersonaConfig {
|
||||
/// One-line description. Required.
|
||||
pub description: String,
|
||||
|
||||
// ── OPS compatibility ─────────────────────────────────────────────────
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
|
||||
// ── Skills & MCP ──────────────────────────────────────────────────────
|
||||
/// Pack-relative paths to skill directories.
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
@@ -137,7 +126,6 @@ pub struct PersonaConfig {
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
|
||||
// ── Behavioral config ─────────────────────────────────────────────────
|
||||
/// Channel names to monitor.
|
||||
///
|
||||
/// - `None` (omitted or `null`) → fall through to pack default
|
||||
@@ -173,18 +161,14 @@ pub struct PersonaConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub broadcast_replies: Option<bool>,
|
||||
|
||||
// ── Hooks ─────────────────────────────────────────────────────────────
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hooks: Option<Hooks>,
|
||||
|
||||
// ── System prompt (markdown body) ─────────────────────────────────────
|
||||
/// The markdown body of the `.persona.md` file.
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
// ── Frontmatter-only intermediate ────────────────────────────────────────────
|
||||
|
||||
/// Deserializes just the YAML frontmatter (no `prompt`).
|
||||
/// Unknown keys are rejected — typos cause parse errors instead of silent drops.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -213,8 +197,6 @@ struct Frontmatter {
|
||||
hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a `.persona.md` file into a [`PersonaConfig`].
|
||||
///
|
||||
/// Expects YAML frontmatter between `---` delimiters followed by a markdown
|
||||
@@ -336,8 +318,6 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), PersonaError> {
|
||||
Ok((fm_str, body))
|
||||
}
|
||||
|
||||
// ── Model string helper ───────────────────────────────────────────────────────
|
||||
|
||||
/// Split `"provider:model-id"` into `(Some("provider"), "model-id")`.
|
||||
///
|
||||
/// If there is no colon, returns `(None, full_string)`.
|
||||
@@ -348,20 +328,14 @@ pub fn split_model(model: &str) -> (Option<&str>, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn minimal() -> &'static str {
|
||||
"---\nname: my-bot\ndisplay_name: My Bot\ndescription: Does things.\n---\n"
|
||||
}
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_valid() {
|
||||
let p = parse_persona_md(minimal()).unwrap();
|
||||
@@ -455,8 +429,6 @@ You are Full Bot.
|
||||
assert!(matches!(err, PersonaError::Yaml(_)), "got: {err}");
|
||||
}
|
||||
|
||||
// ── Missing required fields ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn missing_name_errors() {
|
||||
let src = "---\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
@@ -487,8 +459,6 @@ You are Full Bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty required fields (Fix #1) ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_name_errors() {
|
||||
let src = "---\nname: \"\"\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
@@ -529,8 +499,6 @@ You are Full Bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delimiter errors ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn no_frontmatter_delimiters_errors() {
|
||||
let err = parse_persona_md("Just plain markdown.").unwrap_err();
|
||||
@@ -567,8 +535,6 @@ You are Full Bot.
|
||||
assert_eq!(p.prompt, "Body here.\n");
|
||||
}
|
||||
|
||||
// ── Malformed YAML ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn malformed_yaml_errors() {
|
||||
let src = "---\n: bad: yaml: here\n---\n";
|
||||
@@ -576,8 +542,6 @@ You are Full Bot.
|
||||
assert!(matches!(err, PersonaError::Yaml(_)));
|
||||
}
|
||||
|
||||
// ── Size limits ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn frontmatter_too_large_errors() {
|
||||
// Build a frontmatter that exceeds 1 MiB
|
||||
@@ -595,8 +559,6 @@ You are Full Bot.
|
||||
assert!(matches!(err, PersonaError::BodyTooLarge));
|
||||
}
|
||||
|
||||
// ── split_model ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn split_model_with_colon() {
|
||||
let (provider, id) = split_model("openai:gpt-4o");
|
||||
@@ -618,8 +580,6 @@ You are Full Bot.
|
||||
assert_eq!(id, "gpt-5:preview");
|
||||
}
|
||||
|
||||
// ── Subscribe three-state semantics (S2) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_subscribe_null_is_none() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe: null\n---\n";
|
||||
@@ -678,8 +638,6 @@ You are Full Bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Trim leading newline from indented string literals.
|
||||
fn indoc(s: &str) -> &str {
|
||||
s.strip_prefix('\n').unwrap_or(s)
|
||||
|
||||
@@ -17,8 +17,6 @@ use crate::merge::TriggersData;
|
||||
use crate::pack::{self, LoadedPack, LoadedPersona, PackError};
|
||||
use crate::persona::split_model;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A fully resolved persona — ready for ACP consumption.
|
||||
/// All merge, composition, and projection is done.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -99,8 +97,6 @@ pub struct ResolvedPack {
|
||||
pub personas: Vec<ResolvedPersona>,
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Load, validate, merge, and resolve a pack directory.
|
||||
///
|
||||
/// Returns a `ResolvedPack` with fully typed, ACP-ready output for each
|
||||
@@ -193,8 +189,6 @@ pub fn resolve_persona_by_name(pack_dir: &Path, name: &str) -> Result<ResolvedPe
|
||||
.ok_or_else(|| PackError::PersonaNotFound(pack_dir.join(name)))
|
||||
}
|
||||
|
||||
// ── Per-persona resolution ────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_one_persona(
|
||||
lp: &LoadedPersona,
|
||||
pack_version: &str,
|
||||
@@ -250,8 +244,6 @@ fn resolve_one_persona(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compose the effective system prompt: persona body + pack instructions.
|
||||
fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> String {
|
||||
match pack_instructions {
|
||||
@@ -262,8 +254,6 @@ fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ── Triggers resolution ───────────────────────────────────────────────────────
|
||||
|
||||
/// Convert `TriggersData` to `ResolvedTriggers`.
|
||||
fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers {
|
||||
match rt {
|
||||
@@ -280,8 +270,6 @@ fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers {
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP server merge ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Merge pack-level shared MCP servers with per-persona servers.
|
||||
///
|
||||
/// Pack shared servers come from `.mcp.json` (a map of `name → config`).
|
||||
@@ -352,8 +340,6 @@ fn parse_mcp_server_config(name: &str, config: &serde_json::Value) -> Option<Res
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hooks resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Store hook paths as raw relative strings (no path resolution).
|
||||
///
|
||||
/// Security: we intentionally do NOT resolve these to absolute paths.
|
||||
@@ -373,8 +359,6 @@ fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option<ResolvedHook
|
||||
})
|
||||
}
|
||||
|
||||
// ── Env var projection ────────────────────────────────────────────────────────
|
||||
|
||||
/// Project persona config into agent subprocess env vars.
|
||||
///
|
||||
/// Pure function — does NOT read the current process env.
|
||||
@@ -416,8 +400,6 @@ fn runtime_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
|
||||
vars
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -425,8 +407,6 @@ mod tests {
|
||||
|
||||
use crate::merge::{HooksData, TriggersData};
|
||||
|
||||
// ── compose_prompt ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn compose_prompt_body_only() {
|
||||
let result = compose_prompt("You are a bot.", None);
|
||||
@@ -447,8 +427,6 @@ mod tests {
|
||||
assert_eq!(result, "You are a bot.");
|
||||
}
|
||||
|
||||
// ── resolve_triggers ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn triggers_from_triggers_data() {
|
||||
let data = TriggersData {
|
||||
@@ -470,8 +448,6 @@ mod tests {
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
// ── merge_mcp_servers ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mcp_merge_shared_only() {
|
||||
let shared = serde_json::json!({
|
||||
@@ -552,8 +528,6 @@ mod tests {
|
||||
assert_eq!(env["SECRET"], "${MY_SECRET}");
|
||||
}
|
||||
|
||||
// ── resolve_hooks ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn hooks_stored_as_raw_relative_paths() {
|
||||
// Security: hooks are stored as raw strings, NOT resolved to absolute.
|
||||
@@ -583,8 +557,6 @@ mod tests {
|
||||
assert!(resolve_hooks(None).is_none());
|
||||
}
|
||||
|
||||
// ── runtime_env_vars ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn env_vars_projected_from_model() {
|
||||
let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None);
|
||||
@@ -668,8 +640,6 @@ mod tests {
|
||||
assert!(!map.contains_key("BUZZ_AGENT_PROVIDER"));
|
||||
}
|
||||
|
||||
// ── Full pipeline (resolve_pack via filesystem) ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_minimal_pack() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -820,8 +790,6 @@ mod tests {
|
||||
assert!(lep.thread_replies);
|
||||
}
|
||||
|
||||
// ── resolve_persona_by_name ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_persona_by_name_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -872,8 +840,6 @@ mod tests {
|
||||
assert!(matches!(err, PackError::PersonaNotFound(_)));
|
||||
}
|
||||
|
||||
// ── model split ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn model_split_provider_and_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -922,8 +888,6 @@ mod tests {
|
||||
assert!(p.llm_provider.is_none());
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn stub_persona(
|
||||
model: Option<&str>,
|
||||
temperature: Option<f64>,
|
||||
|
||||
@@ -14,8 +14,6 @@ use std::path::Path;
|
||||
|
||||
use crate::pack;
|
||||
|
||||
// ── Diagnostics ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single validation finding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ValidationDiagnostic {
|
||||
@@ -97,8 +95,6 @@ impl std::fmt::Display for ValidationReport {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known field sets ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Known top-level keys in `plugin.json`.
|
||||
const KNOWN_MANIFEST_KEYS: &[&str] = &[
|
||||
// OPS standard fields
|
||||
@@ -136,8 +132,6 @@ const KNOWN_BEHAVIORAL_KEYS: &[&str] = &[
|
||||
/// Valid sub-keys in `respond_to`.
|
||||
const KNOWN_RESPOND_TO_KEYS: &[&str] = &["mentions", "keywords", "all_messages"];
|
||||
|
||||
// ── Pack validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate a persona pack directory.
|
||||
///
|
||||
/// Step 1: delegate all structural validation to `load_pack()`. If loading
|
||||
@@ -169,8 +163,6 @@ pub fn validate_pack(pack_dir: &Path) -> ValidationReport {
|
||||
report
|
||||
}
|
||||
|
||||
// ── Semantic: persona-level checks ───────────────────────────────────────────
|
||||
|
||||
/// Validate a persona `name` field: `[a-zA-Z0-9_-]+`, max 64 chars.
|
||||
fn validate_persona_name(name: &str, report: &mut ValidationReport) {
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
@@ -210,8 +202,6 @@ fn semantic_check_personas(loaded: &pack::LoadedPack, report: &mut ValidationRep
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advisory: respond_to type validation ────────────────────────────────────
|
||||
|
||||
/// Check `defaults.respond_to` sub-key types in the raw `plugin.json`.
|
||||
///
|
||||
/// The typed parser (serde) already rejects wrong types in persona frontmatter,
|
||||
@@ -307,8 +297,6 @@ fn value_type_name(v: &serde_json::Value) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advisory: unknown manifest keys ─────────────────────────────────────────
|
||||
|
||||
/// Check `plugin.json` for unknown top-level keys and unknown keys in
|
||||
/// `defaults` / `defaults.respond_to`. Emits warnings (likely typos).
|
||||
fn advisory_check_manifest_keys(pack_dir: &Path, report: &mut ValidationReport) {
|
||||
@@ -361,8 +349,6 @@ fn advisory_check_manifest_keys(pack_dir: &Path, report: &mut ValidationReport)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advisory: skill naming conventions ──────────────────────────────────────
|
||||
|
||||
/// For each skill directory referenced by a loaded persona, check that the
|
||||
/// SKILL.md `name:` field matches the directory name. Emits warnings.
|
||||
fn advisory_check_skill_names(
|
||||
@@ -447,14 +433,10 @@ fn advisory_check_skill_names(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ValidationReport unit tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn exit_code_clean() {
|
||||
let report = ValidationReport::default();
|
||||
@@ -475,8 +457,6 @@ mod tests {
|
||||
assert_eq!(report.exit_code(), 2);
|
||||
}
|
||||
|
||||
// ── Filesystem integration tests ─────────────────────────────────────────
|
||||
|
||||
/// Minimal valid pack: load succeeds, no advisory issues.
|
||||
#[test]
|
||||
fn validate_pack_minimal_valid() {
|
||||
@@ -805,8 +785,6 @@ mod tests {
|
||||
assert!(msg.contains("code_review"), "got: {msg}");
|
||||
}
|
||||
|
||||
// ── Semantic: zero-persona and duplicate-name checks ─────────────────────
|
||||
|
||||
/// Zero personas in manifest → hard error.
|
||||
#[test]
|
||||
fn validate_zero_personas_error() {
|
||||
@@ -909,8 +887,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Semantic: name character and length validation ────────────────────────
|
||||
|
||||
/// Persona name with spaces or slashes → hard error.
|
||||
#[test]
|
||||
fn validate_name_invalid_chars() {
|
||||
@@ -968,8 +944,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Advisory: respond_to type validation ─────────────────────────────────
|
||||
|
||||
/// respond_to with wrong types in defaults → caught by typed parser.
|
||||
/// The manifest's BehavioralDefaults uses typed RespondTo, so serde_json
|
||||
/// rejects wrong types during load_pack(). This surfaces as a load error.
|
||||
|
||||
@@ -12,8 +12,6 @@ use std::fs;
|
||||
|
||||
use buzz_persona::resolve::resolve_pack;
|
||||
|
||||
// ── Import filter (replicates desktop crate logic) ───────────────────────────
|
||||
|
||||
const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[
|
||||
"GOOSE_MODEL",
|
||||
"GOOSE_PROVIDER",
|
||||
@@ -32,8 +30,6 @@ fn filter_derived(env_vars: Vec<(String, String)>) -> BTreeMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Test 1: Goose persona emits correct runtime env vars ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_pack_goose_persona_emits_correct_runtime_env_vars() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -94,8 +90,6 @@ You are a test bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 2: Buzz-agent persona emits BUZZ_AGENT_* vars ───────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_pack_buzz_agent_persona_emits_buzz_agent_vars() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -161,8 +155,6 @@ You are a test bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 3: Import filter strips derived keys, preserves knobs ───────────────
|
||||
|
||||
#[test]
|
||||
fn import_filter_strips_derived_preserves_knobs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -221,8 +213,6 @@ You are a test bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 4: Two runtimes in one pack get different env var prefixes ───────────
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_two_runtimes_different_env_vars() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -327,8 +317,6 @@ You are a buzz bot.
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 5: Model without provider prefix emits model only ───────────────────
|
||||
|
||||
#[test]
|
||||
fn model_without_provider_prefix_emits_model_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -12,8 +12,6 @@ use buzz_persona::persona;
|
||||
use buzz_persona::resolve;
|
||||
use buzz_persona::validate;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a minimal valid pack in a temp directory.
|
||||
/// Returns the temp dir (holds the lifetime) and the pack root path.
|
||||
fn create_test_pack(dir: &Path) {
|
||||
@@ -117,8 +115,6 @@ When asked to review code, follow these steps...
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Full pipeline: load → parse → validate ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_load_and_validate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -220,8 +216,6 @@ fn full_pipeline_load_and_validate() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Persona parser round-trip ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_parse_round_trip() {
|
||||
let md = r###"---
|
||||
@@ -266,8 +260,6 @@ You are a test agent. Be precise and thorough.
|
||||
assert_eq!(rt.all_messages, Some(false));
|
||||
}
|
||||
|
||||
// ── Validation catches real errors ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validation_catches_missing_required_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -344,8 +336,6 @@ fn validation_catches_unknown_behavioral_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── model string splitting ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn model_split_cases() {
|
||||
// provider:model
|
||||
@@ -364,8 +354,6 @@ fn model_split_cases() {
|
||||
assert_eq!(model, "my:model:v2");
|
||||
}
|
||||
|
||||
// ── Defaults merge: persona overrides pack defaults ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn defaults_merge_persona_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -395,8 +383,6 @@ fn defaults_merge_persona_overrides() {
|
||||
assert_eq!(lep.max_context_tokens, Some(128000));
|
||||
}
|
||||
|
||||
// ── Resolve pipeline: full end-to-end ────────────────────────────────────────
|
||||
|
||||
/// Build a pack on disk → resolve → verify all fields on each persona.
|
||||
#[test]
|
||||
fn resolve_full_pipeline() {
|
||||
@@ -590,8 +576,6 @@ fn resolve_persona_by_name_not_found() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Validation: zero-persona and duplicate-name in full pipeline ─────────────
|
||||
|
||||
/// Validation catches zero-persona packs in the full pipeline.
|
||||
#[test]
|
||||
fn validate_zero_personas_in_pipeline() {
|
||||
|
||||
@@ -11,8 +11,6 @@ use serde::Deserialize;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── DTOs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal DTO for deserializing `GET /api/channels` response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChannelDto {
|
||||
@@ -32,8 +30,6 @@ pub struct ChannelDto {
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
// ─── ChannelInfo ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// All relevant information about a mapped channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelInfo {
|
||||
@@ -51,8 +47,6 @@ pub struct ChannelInfo {
|
||||
pub created_at_unix: u64,
|
||||
}
|
||||
|
||||
// ─── ChannelMap ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bidirectional UUID ↔ kind:40 event ID map.
|
||||
///
|
||||
/// Thread-safe via [`DashMap`]; clone-friendly via `Arc` wrapping at the call
|
||||
@@ -63,8 +57,6 @@ pub struct ChannelMap {
|
||||
server_keys: Keys,
|
||||
}
|
||||
|
||||
// ─── Synthesis helpers ───────────────────────────────────────────────────────
|
||||
|
||||
impl ChannelMap {
|
||||
/// Synthesize a deterministic NIP-28 kind:40 channel creation event.
|
||||
///
|
||||
@@ -118,8 +110,6 @@ impl ChannelMap {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core impl ───────────────────────────────────────────────────────────────
|
||||
|
||||
impl ChannelMap {
|
||||
/// Create an empty [`ChannelMap`] with the given server signing keys.
|
||||
pub fn new(server_keys: Keys) -> Self {
|
||||
@@ -167,8 +157,6 @@ impl ChannelMap {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ─── Lookups ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up channel info by Buzz UUID.
|
||||
pub fn lookup_by_uuid(&self, uuid: &Uuid) -> Option<ChannelInfo> {
|
||||
self.by_uuid.get(uuid).map(|r| r.clone())
|
||||
@@ -186,8 +174,6 @@ impl ChannelMap {
|
||||
self.by_uuid.iter().map(|r| r.value().clone()).collect()
|
||||
}
|
||||
|
||||
// ─── Mutation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register a new channel (e.g. from a kind:40099 system message) and
|
||||
/// return its [`ChannelInfo`].
|
||||
///
|
||||
@@ -225,8 +211,6 @@ impl ChannelMap {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
// ─── Accessors ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Number of channels in the map.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_uuid.len()
|
||||
@@ -243,8 +227,6 @@ impl ChannelMap {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,8 +14,6 @@ use buzz_proxy::shadow_keys::ShadowKeyManager;
|
||||
use buzz_proxy::translate::Translator;
|
||||
use buzz_proxy::upstream::{UpstreamClient, UpstreamEvent};
|
||||
|
||||
// ── Env helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn env_required(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| {
|
||||
eprintln!("error: required environment variable {name} is not set");
|
||||
@@ -27,8 +25,6 @@ fn env_or(name: &str, default: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Init tracing — respects RUST_LOG; falls back to info for buzz_proxy and tower_http.
|
||||
@@ -39,8 +35,6 @@ async fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
// ── Parse env ─────────────────────────────────────────────────────────────
|
||||
|
||||
let upstream_url = env_required("BUZZ_UPSTREAM_URL");
|
||||
let bind_addr = env_or("BUZZ_PROXY_BIND_ADDR", "0.0.0.0:4869");
|
||||
let server_key_hex = env_required("BUZZ_PROXY_SERVER_KEY");
|
||||
@@ -55,8 +49,6 @@ async fn main() {
|
||||
}
|
||||
info!(relay_pubkey = %relay_pubkey, "relay pubkey configured for attribution trust");
|
||||
|
||||
// ── Parse server keypair ──────────────────────────────────────────────────
|
||||
|
||||
let server_secret = SecretKey::from_hex(&server_key_hex).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid BUZZ_PROXY_SERVER_KEY: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -64,28 +56,20 @@ async fn main() {
|
||||
let server_keys = Keys::new(server_secret);
|
||||
info!(pubkey = %server_keys.public_key(), "proxy server keypair loaded");
|
||||
|
||||
// ── Parse salt ────────────────────────────────────────────────────────────
|
||||
|
||||
let salt = hex::decode(&salt_hex).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid BUZZ_PROXY_SALT (must be hex): {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// ── Init shadow key manager ───────────────────────────────────────────────
|
||||
|
||||
let shadow_keys = Arc::new(ShadowKeyManager::new(&salt).unwrap_or_else(|e| {
|
||||
eprintln!("error: shadow key manager init failed: {e}");
|
||||
std::process::exit(1);
|
||||
}));
|
||||
|
||||
// ── Derive HTTP base URL from WS URL for REST API calls ───────────────────
|
||||
|
||||
let api_base = upstream_url
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://");
|
||||
|
||||
// ── Init channel map from REST API ────────────────────────────────────────
|
||||
|
||||
info!("initializing channel map from {api_base}/api/channels ...");
|
||||
let channel_map = Arc::new(
|
||||
ChannelMap::init_from_rest(server_keys.clone(), &api_base, &api_token)
|
||||
@@ -97,8 +81,6 @@ async fn main() {
|
||||
);
|
||||
info!(channels = channel_map.len(), "channel map ready");
|
||||
|
||||
// ── Init translator ───────────────────────────────────────────────────────
|
||||
|
||||
let translator = Arc::new(Translator::new(
|
||||
shadow_keys,
|
||||
channel_map.clone(),
|
||||
@@ -107,15 +89,10 @@ async fn main() {
|
||||
relay_pubkey,
|
||||
));
|
||||
|
||||
// ── Init guest store (empty — guests registered via POST /admin/guests) ────
|
||||
|
||||
let guest_store = Arc::new(GuestStore::new());
|
||||
|
||||
// ── Init invite store (empty — tokens created via POST /admin/invite) ─────
|
||||
|
||||
let invite_store = Arc::new(InviteStore::new());
|
||||
|
||||
// ── Init upstream client ──────────────────────────────────────────────────
|
||||
//
|
||||
// UpstreamClient owns its internal outbound channel. The server calls
|
||||
// upstream.send_event() / send_req() / send_close() directly via Arc.
|
||||
@@ -130,15 +107,12 @@ async fn main() {
|
||||
server_keys.clone(),
|
||||
));
|
||||
|
||||
// ── upstream_events broadcast: UpstreamClient → all WebSocket sessions ────
|
||||
|
||||
// upstream_events_tx: upstream → server (broadcast of inbound JSON strings)
|
||||
let (upstream_events_tx, _) = broadcast::channel::<String>(4096);
|
||||
|
||||
// inbound_tx: UpstreamClient → bridge task (UpstreamEvent)
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<UpstreamEvent>(256);
|
||||
|
||||
// ── Bridge task: UpstreamEvent → broadcast String ─────────────────────────
|
||||
//
|
||||
// The server layer subscribes to `upstream_events_tx` as raw JSON strings.
|
||||
// The UpstreamClient emits typed `UpstreamEvent` values. This task bridges
|
||||
@@ -162,8 +136,6 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Read admin secret from env (optional) ─────────────────────────────────
|
||||
|
||||
let admin_secret = std::env::var("BUZZ_PROXY_ADMIN_SECRET").ok();
|
||||
if admin_secret.is_some() {
|
||||
info!("admin endpoint protected by BUZZ_PROXY_ADMIN_SECRET");
|
||||
@@ -171,8 +143,6 @@ async fn main() {
|
||||
info!("admin endpoint running unauthenticated (dev mode) — set BUZZ_PROXY_ADMIN_SECRET to secure it");
|
||||
}
|
||||
|
||||
// ── Build proxy state ─────────────────────────────────────────────────────
|
||||
|
||||
// Relay URL for NIP-42 relay tag validation. Prefer explicit env var
|
||||
// (e.g. "wss://proxy.example.com") over the derived bind address fallback.
|
||||
let relay_url =
|
||||
@@ -189,12 +159,8 @@ async fn main() {
|
||||
relay_url,
|
||||
};
|
||||
|
||||
// ── Build router ──────────────────────────────────────────────────────────
|
||||
|
||||
let app = server::router(state);
|
||||
|
||||
// ── Bind listener ─────────────────────────────────────────────────────────
|
||||
|
||||
info!("buzz-proxy starting on {bind_addr} → upstream {upstream_url}");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&bind_addr)
|
||||
@@ -204,8 +170,6 @@ async fn main() {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// ── Run server + upstream concurrently ────────────────────────────────────
|
||||
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()) => {
|
||||
if let Err(e) = result {
|
||||
@@ -220,8 +184,6 @@ async fn main() {
|
||||
info!("buzz-proxy shut down");
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn shutdown_signal() {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
|
||||
@@ -29,8 +29,6 @@ use crate::invite_store::InviteStore;
|
||||
use crate::translate::Translator;
|
||||
use crate::upstream::UpstreamClient;
|
||||
|
||||
// ─── Shared state ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Shared state injected into every axum handler.
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyState {
|
||||
@@ -56,8 +54,6 @@ pub struct ProxyState {
|
||||
pub relay_url: String,
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Query parameters accepted on the root WebSocket endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub struct WsParams {
|
||||
@@ -88,8 +84,6 @@ pub fn router(state: ProxyState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ─── Root handler (NIP-11 / WebSocket) ───────────────────────────────────────
|
||||
|
||||
/// Content-negotiate between NIP-11 JSON and WebSocket upgrade.
|
||||
///
|
||||
/// Uses `axum::extract::Request` to manually attempt the WS upgrade so that
|
||||
@@ -142,8 +136,6 @@ fn nip11_response() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Constant-time string comparison ─────────────────────────────────────────
|
||||
|
||||
/// Compare two strings in constant time to prevent timing side-channel attacks.
|
||||
/// Returns `true` only if both strings are identical.
|
||||
///
|
||||
@@ -161,8 +153,6 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
== 0
|
||||
}
|
||||
|
||||
// ─── WebSocket handler ───────────────────────────────────────────────────────
|
||||
|
||||
/// Helper: serialize a [`RelayMessage`] and send it over the socket.
|
||||
/// Returns `true` if the send succeeded.
|
||||
async fn send_relay_msg(socket: &mut WebSocket, msg: RelayMessage<'_>) -> bool {
|
||||
@@ -176,7 +166,6 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
|
||||
// across clients sharing the single upstream connection.
|
||||
let conn_prefix = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
|
||||
|
||||
// ── 1. Send NIP-42 AUTH challenge ─────────────────────────────────────
|
||||
// Token validation is deferred until after NIP-42 auth completes.
|
||||
// Registered guests (in GuestStore) don't need a token at all.
|
||||
let challenge = uuid::Uuid::new_v4().to_string();
|
||||
@@ -184,7 +173,6 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Pre-auth loop: reject pre-auth REQs/EVENTs, wait for AUTH ─────
|
||||
// Returns `(pubkey, channels)` on successful auth, or drops the connection
|
||||
// on timeout / disconnect / invalid auth.
|
||||
let auth_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
@@ -270,7 +258,6 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Resolve channel access ────────────────────────────
|
||||
// Priority: GuestStore (pubkey-based) > invite token.
|
||||
let pubkey = auth_event.pubkey;
|
||||
let event_id = auth_event.id;
|
||||
@@ -355,10 +342,8 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
|
||||
let mut pending_oks: HashMap<String, EventId> = HashMap::new();
|
||||
let mut active_subs: HashSet<String> = HashSet::new();
|
||||
|
||||
// ── 4. Subscribe to upstream broadcast ────────────────────────────────
|
||||
let mut upstream_rx = state.upstream_events.subscribe();
|
||||
|
||||
// ── 5. Main authenticated message loop ────────────────────────────────
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Inbound from client
|
||||
@@ -491,8 +476,6 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
|
||||
debug!(pubkey = %client_pubkey, "client disconnected");
|
||||
}
|
||||
|
||||
// ─── Client message dispatcher ───────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_client_message(
|
||||
socket: &mut WebSocket,
|
||||
@@ -602,8 +585,6 @@ async fn handle_client_message(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filter splitting (pure, testable) ───────────────────────────────────────
|
||||
|
||||
/// Split a list of NIP-28 filters into local (kind:40/41) and upstream groups.
|
||||
///
|
||||
/// **Routing rules:**
|
||||
@@ -751,8 +732,6 @@ fn collect_local_events(
|
||||
events
|
||||
}
|
||||
|
||||
// ─── REQ handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_req(
|
||||
socket: &mut WebSocket,
|
||||
state: &ProxyState,
|
||||
@@ -804,8 +783,6 @@ async fn handle_req(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin: create invite token ───────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateInviteRequest {
|
||||
/// Comma-separated channel UUIDs this token grants access to.
|
||||
@@ -883,8 +860,6 @@ async fn create_invite(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ─── Admin: check secret helper ───────────────────────────────────────────────
|
||||
|
||||
/// Verify the admin secret from the Authorization header. Returns an error
|
||||
/// response if the secret is required but missing/wrong, or `None` if OK.
|
||||
fn check_admin_secret(admin_secret: &Option<String>, headers: &HeaderMap) -> Option<Response> {
|
||||
@@ -910,8 +885,6 @@ fn check_admin_secret(admin_secret: &Option<String>, headers: &HeaderMap) -> Opt
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin: guest registration ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterGuestRequest {
|
||||
/// Hex-encoded Nostr public key (64 chars).
|
||||
@@ -1034,8 +1007,6 @@ async fn list_guests(State(state): State<ProxyState>, headers: HeaderMap) -> imp
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1103,8 +1074,6 @@ mod tests {
|
||||
assert!(!constant_time_eq("abc", "abcd"));
|
||||
}
|
||||
|
||||
// ── split_filters tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn split_filters_pure_local() {
|
||||
// kind:40 is the only pure-local kind (channel creation is synthesized).
|
||||
@@ -1183,8 +1152,6 @@ mod tests {
|
||||
assert!(upstream[0].kinds.is_none());
|
||||
}
|
||||
|
||||
// ── collect_local_events tests ───────────────────────────────────────
|
||||
|
||||
fn make_channel_map_with_channel() -> (Arc<ChannelMap>, Uuid) {
|
||||
let keys = Keys::generate();
|
||||
let map = ChannelMap::new(keys);
|
||||
|
||||
@@ -30,8 +30,6 @@ use crate::kind_translator::KindTranslator;
|
||||
use crate::shadow_keys::ShadowKeyManager;
|
||||
use crate::ProxyError;
|
||||
|
||||
// ─── Translator ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Translates events and filters between Buzz internal format and NIP-28
|
||||
/// standard format.
|
||||
///
|
||||
@@ -89,8 +87,6 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Outbound translation (Buzz → NIP-28 client) ───────────────────────────
|
||||
|
||||
impl Translator {
|
||||
fn cache_event_mapping(&self, internal_event_id: &str, external_event_id: &str) {
|
||||
self.internal_to_external_event_ids
|
||||
@@ -394,8 +390,6 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inbound translation (NIP-28 client → Buzz) ────────────────────────────
|
||||
|
||||
impl Translator {
|
||||
/// Translate a NIP-28 event from an external client into Buzz format.
|
||||
///
|
||||
@@ -662,8 +656,6 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filter translation ───────────────────────────────────────────────────────
|
||||
|
||||
impl Translator {
|
||||
/// Translate a NIP-28 REQ filter to Buzz format.
|
||||
///
|
||||
@@ -745,8 +737,6 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract plain text from V2 rich content JSON.
|
||||
///
|
||||
/// V2 content is a JSON object with a `"text"` field. Falls back to the raw
|
||||
@@ -758,16 +748,12 @@ fn extract_plain_text(content: &str) -> String {
|
||||
.unwrap_or_else(|| content.to_string())
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channel_map::{ChannelDto, ChannelMap};
|
||||
use buzz_core::kind::KIND_STREAM_MESSAGE;
|
||||
|
||||
// ── Test fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_UUID: &str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const TEST_SALT: &[u8] = b"test-salt-for-translate-tests";
|
||||
|
||||
@@ -815,8 +801,6 @@ mod tests {
|
||||
vec![]
|
||||
}
|
||||
|
||||
// ── Test 1: Outbound — kind:9 + #h → kind:42 + #e ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_translates_stream_message() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -865,8 +849,6 @@ mod tests {
|
||||
.expect("translated event signature must be valid");
|
||||
}
|
||||
|
||||
// ── Test 2: Inbound — kind:42 + #e → kind:9 + #h ───────────────────
|
||||
|
||||
#[test]
|
||||
fn inbound_translates_channel_message() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -917,8 +899,6 @@ mod tests {
|
||||
.expect("translated event signature must be valid");
|
||||
}
|
||||
|
||||
// ── Test 3: Outbound — channel not in allowed_channels → PermissionDenied
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_rejects_channel_not_in_scope() {
|
||||
let (translator, _) = make_translator();
|
||||
@@ -941,8 +921,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 4: Inbound — channel not in allowed_channels → PermissionDenied
|
||||
|
||||
#[test]
|
||||
fn inbound_rejects_channel_not_in_scope() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -964,8 +942,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 5: V2 content extraction ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn v2_content_plain_text_extracted() {
|
||||
// extract_plain_text is a private helper; test it indirectly via outbound.
|
||||
@@ -998,8 +974,6 @@ mod tests {
|
||||
assert_eq!(content, "not json at all");
|
||||
}
|
||||
|
||||
// ── Test 6: Filter translation — kind:42 → kind:9 ───────────────────
|
||||
|
||||
#[test]
|
||||
fn filter_inbound_translates_kind() {
|
||||
let (translator, _) = make_translator();
|
||||
@@ -1024,8 +998,6 @@ mod tests {
|
||||
assert!(has_h_filter, "filter must have #h tag constraints injected");
|
||||
}
|
||||
|
||||
// ── Test 6b: Filter — #e channel ref translates to #h UUID (FIX A) ─────
|
||||
|
||||
#[test]
|
||||
fn filter_inbound_translates_e_tag_to_h() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -1068,8 +1040,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 6c: Filter — #e with unknown event ID falls back to allowed_channels
|
||||
|
||||
#[test]
|
||||
fn filter_inbound_e_tag_unknown_event_id_denies_all() {
|
||||
let (translator, _) = make_translator();
|
||||
@@ -1110,8 +1080,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 7: Inbound — reply #e tags are preserved (FIX 1) ───────────────
|
||||
|
||||
#[test]
|
||||
fn inbound_preserves_reply_e_tags() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -1165,8 +1133,6 @@ mod tests {
|
||||
.expect("translated event signature must be valid");
|
||||
}
|
||||
|
||||
// ── Test 8: Outbound — non-channel #h tags are preserved (FIX 2) ────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_preserves_non_channel_h_tags() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -1217,8 +1183,6 @@ mod tests {
|
||||
.expect("translated event signature must be valid");
|
||||
}
|
||||
|
||||
// ── Test 9: Filter — empty allowed_channels injects deny-all (FIX 3) ────
|
||||
|
||||
#[test]
|
||||
fn empty_allowed_channels_denies_all() {
|
||||
let (translator, _) = make_translator();
|
||||
@@ -1241,8 +1205,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 10: Inbound — kind:41 (edit) → kind:40003 ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn inbound_translates_edit_message() {
|
||||
use buzz_core::kind::KIND_STREAM_MESSAGE_EDIT;
|
||||
@@ -1284,8 +1246,6 @@ mod tests {
|
||||
.expect("translated edit signature must be valid");
|
||||
}
|
||||
|
||||
// ── Test 11: Inbound — rejects unknown kinds ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn inbound_rejects_unknown_kind() {
|
||||
let (translator, kind40_event_id) = make_translator();
|
||||
@@ -1302,8 +1262,6 @@ mod tests {
|
||||
assert!(result.is_err(), "kind:9999 must be rejected inbound");
|
||||
}
|
||||
|
||||
// ── Test 12: Outbound — kind:40003 (edit) → kind:41 (FIX 4) ─────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_translates_edit_message() {
|
||||
use buzz_core::kind::KIND_STREAM_MESSAGE_EDIT;
|
||||
@@ -1390,8 +1348,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 15: Outbound — unknown kinds are dropped (not leaked) ───────
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_drops_unknown_kinds() {
|
||||
let (translator, _) = make_translator();
|
||||
|
||||
@@ -13,8 +13,6 @@ use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
// ── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Messages forwarded from the upstream relay to the server layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UpstreamEvent {
|
||||
@@ -26,8 +24,6 @@ pub enum UpstreamEvent {
|
||||
Connected,
|
||||
}
|
||||
|
||||
// ── UpstreamClient ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inner state shared across the `Arc`. Kept separate so `Arc<Inner>` can be
|
||||
/// moved into `'static` spawned tasks without capturing `&self`.
|
||||
struct Inner {
|
||||
@@ -111,8 +107,6 @@ impl UpstreamClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Send an event to the upstream relay.
|
||||
pub async fn send_event(&self, event: Event) -> Result<(), crate::ProxyError> {
|
||||
let msg = ClientMessage::event(event).as_json();
|
||||
@@ -155,8 +149,6 @@ impl UpstreamClient {
|
||||
self.inner.connected.try_read().map(|v| *v).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── Run loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run the upstream connection loop. Reconnects on disconnect with exponential
|
||||
/// backoff (1 → 2 → 4 → … → 30 seconds).
|
||||
///
|
||||
@@ -185,8 +177,6 @@ impl UpstreamClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: single connection attempt ──────────────────────────────────
|
||||
|
||||
/// Establish one WebSocket connection, authenticate, and pump messages until
|
||||
/// the socket closes or an error occurs.
|
||||
async fn connect_once(
|
||||
@@ -255,7 +245,6 @@ impl UpstreamClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Free function: read loop ──────────────────────────────────────────────────
|
||||
//
|
||||
// Extracted as a free function so it does not capture `&self` — it receives
|
||||
// only the `Arc<Inner>` it needs, which is `'static`.
|
||||
@@ -287,7 +276,6 @@ where
|
||||
};
|
||||
|
||||
match relay_msg {
|
||||
// ── NIP-42 AUTH challenge ────────────────────────────────
|
||||
RelayMessage::Auth { ref challenge } => {
|
||||
debug!("received AUTH challenge: {challenge}");
|
||||
match respond_to_auth_challenge(challenge, &inner, write_tx).await {
|
||||
@@ -299,7 +287,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// ── OK response — check if it's for our auth event ───────
|
||||
RelayMessage::Ok {
|
||||
event_id,
|
||||
ref status,
|
||||
@@ -360,7 +347,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// ── All other messages → forward downstream ──────────────
|
||||
_other => {
|
||||
if inbound_tx
|
||||
.send(UpstreamEvent::RelayMessage(text_str.to_string()))
|
||||
@@ -397,8 +383,6 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Free function: NIP-42 auth response ──────────────────────────────────────
|
||||
|
||||
/// Build and send a NIP-42 kind:22242 auth event in response to a challenge.
|
||||
/// Stores the auth event's ID in `inner.auth_event_id` so the read loop can
|
||||
/// correlate the OK response to this specific event.
|
||||
@@ -435,8 +419,6 @@ async fn respond_to_auth_challenge(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -54,7 +54,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
.map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}"))?;
|
||||
eprintln!("[smoke] MeshLLM host runtime initialized");
|
||||
|
||||
// ── 1. Serve node ────────────────────────────────────────────────────────
|
||||
let serve_cfg = serve::EmbeddedServeConfig::builder()
|
||||
.model(&model)
|
||||
.api_port(SERVE_API_PORT)
|
||||
@@ -87,7 +86,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let serve_model_id = wait_for_model(&http, &serve_base).await?;
|
||||
eprintln!("[smoke] serve model ready: {serve_model_id}");
|
||||
|
||||
// ── 2. Client node, joined to the serve node ─────────────────────────────
|
||||
let client_cfg = client::EmbeddedClientConfig::builder()
|
||||
.api_port(CLIENT_API_PORT)
|
||||
.console_port(CLIENT_CONSOLE_PORT)
|
||||
@@ -108,7 +106,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let routed_model_id = wait_for_model(&http, &client_base).await?;
|
||||
eprintln!("[smoke] client sees routed model: {routed_model_id}");
|
||||
|
||||
// ── 3. One real completion, through the CLIENT (mesh hop) ─────────────────
|
||||
let chat_url = format!("{client_base}/chat/completions");
|
||||
let req = serde_json::json!({
|
||||
"model": routed_model_id,
|
||||
|
||||
@@ -18,8 +18,6 @@ use crate::state::AppState;
|
||||
|
||||
use super::{api_error, internal_error, not_found};
|
||||
|
||||
// ── NIP-98 verification ──────────────────────────────────────────────────────
|
||||
|
||||
/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
|
||||
///
|
||||
/// Returns the authenticated public key and an event ID for replay detection.
|
||||
@@ -104,8 +102,6 @@ fn canonical_url(relay_url: &str, path: &str) -> String {
|
||||
format!("{base}{path}")
|
||||
}
|
||||
|
||||
// ── Channel access helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract a channel UUID from a single filter's `#h` tag.
|
||||
fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
|
||||
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
|
||||
@@ -118,7 +114,6 @@ fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Custom filter field extractors ──────────────────────────────────────────
|
||||
//
|
||||
// The CLI injects extension fields (before_id, depth_limit, feed_types) into
|
||||
// Nostr filter JSON. nostr::Filter silently drops unknown fields during
|
||||
@@ -162,8 +157,6 @@ fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid::
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /events ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Submit a signed Nostr event via HTTP bridge (NIP-98 auth).
|
||||
pub async fn submit_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -233,8 +226,6 @@ pub async fn submit_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /query ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Query events via HTTP bridge (NIP-98 auth). Returns JSON array of events.
|
||||
///
|
||||
/// Enforces channel access: results are filtered to channels the user can access.
|
||||
@@ -295,7 +286,6 @@ pub async fn query_events(
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
|
||||
|
||||
// ── NIP-50 search: route to Typesense if any filter has a `search` field ──
|
||||
if filters.iter().any(|f| f.search.is_some()) {
|
||||
return handle_bridge_search(
|
||||
&state,
|
||||
@@ -307,7 +297,6 @@ pub async fn query_events(
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ──
|
||||
if let Some(presence_events) = synthesize_presence(&state, &filters).await {
|
||||
return Ok(Json(Value::Array(presence_events)));
|
||||
}
|
||||
@@ -315,7 +304,6 @@ pub async fn query_events(
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut handled: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
|
||||
// ── feed_types: route to dedicated feed query functions ──
|
||||
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
|
||||
let feed_types = match extract_feed_types(raw) {
|
||||
Some(t) => t,
|
||||
@@ -380,7 +368,6 @@ pub async fn query_events(
|
||||
handled.insert(idx);
|
||||
}
|
||||
|
||||
// ── depth_limit: route thread queries to get_thread_replies ──
|
||||
let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E);
|
||||
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
|
||||
if handled.contains(&idx) {
|
||||
@@ -432,7 +419,6 @@ pub async fn query_events(
|
||||
handled.insert(idx);
|
||||
}
|
||||
|
||||
// ── Standard query path (with before_id injection) ──
|
||||
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
|
||||
if handled.contains(&idx) {
|
||||
continue;
|
||||
@@ -492,8 +478,6 @@ pub async fn query_events(
|
||||
Ok(Json(Value::Array(events)))
|
||||
}
|
||||
|
||||
// ── POST /count ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`.
|
||||
///
|
||||
/// Enforces channel access: only counts events in channels the user can access.
|
||||
@@ -653,8 +637,6 @@ pub async fn count_events(
|
||||
Ok(Json(serde_json::json!({ "count": total })))
|
||||
}
|
||||
|
||||
// ── NIP-50 search via HTTP bridge ────────────────────────────────────────────
|
||||
|
||||
/// Decide whether a search hit should be returned to the caller.
|
||||
///
|
||||
/// Mirrors the WS NIP-50 path's post-filter step in `handlers/req.rs`:
|
||||
@@ -826,8 +808,6 @@ async fn handle_bridge_search(
|
||||
Ok(Json(Value::Array(events)))
|
||||
}
|
||||
|
||||
// ── POST /hooks/{id} — Webhook trigger ───────────────────────────────────────
|
||||
|
||||
/// Query parameters for the webhook trigger endpoint.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WebhookQuery {
|
||||
@@ -972,8 +952,6 @@ pub async fn workflow_webhook(
|
||||
))
|
||||
}
|
||||
|
||||
// ── Presence synthesis from Redis ────────────────────────────────────────────
|
||||
|
||||
/// If all filters target kind:20001 or kind:40902 with authors, synthesize
|
||||
/// presence from Redis instead of querying the DB (ephemeral events are never
|
||||
/// stored, and kind:40902 snapshots are relay-generated on demand).
|
||||
@@ -1150,8 +1128,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom filter field extractor tests ──
|
||||
|
||||
#[test]
|
||||
fn extract_before_id_valid_hex() {
|
||||
let hex = "a".repeat(64);
|
||||
|
||||
@@ -20,8 +20,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, TagKind};
|
||||
|
||||
// ── Inputs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subset of `Manifest` needed to emit a kind:30618 event.
|
||||
///
|
||||
/// Deliberately a borrowed slice of fields (not the whole `Manifest`) so this
|
||||
@@ -54,8 +52,6 @@ pub enum BuildError {
|
||||
Sign(String),
|
||||
}
|
||||
|
||||
// ── Build helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build & sign a kind:30618 event from the manifest's ref state.
|
||||
///
|
||||
/// Signed with `relay_keys` — the relay is the authoritative source of ref
|
||||
@@ -117,8 +113,6 @@ pub fn build_ref_state_event(
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
// ── Validators (private) ─────────────────────────────────────────────────────
|
||||
|
||||
/// NIP-34 kind:30618 only emits refs under heads/ and tags/.
|
||||
fn is_emittable_ref(name: &str) -> bool {
|
||||
if !(name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) {
|
||||
@@ -136,8 +130,6 @@ fn is_valid_oid(s: &str) -> bool {
|
||||
matches!(s.len(), 40 | 64) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -182,8 +174,6 @@ mod tests {
|
||||
tags_with_kind(ev, kind).into_iter().next()
|
||||
}
|
||||
|
||||
// ── Empty repo (creation event) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_repo_emits_d_head_p_only() {
|
||||
let owner = owner_hex();
|
||||
@@ -213,8 +203,6 @@ mod tests {
|
||||
assert!(tags_with_kind(&ev, "refs/heads/main").is_empty());
|
||||
}
|
||||
|
||||
// ── HEAD wrapping (the gotcha) ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn head_tag_always_wraps_with_ref_prefix() {
|
||||
// Even if a caller is sloppy and passes head with the prefix already...
|
||||
@@ -231,8 +219,6 @@ mod tests {
|
||||
assert_eq!(first_tag(&ev, "HEAD").unwrap()[1], "ref: refs/heads/dev");
|
||||
}
|
||||
|
||||
// ── Branch + tag refs ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn emits_branches_and_tags() {
|
||||
let refs = refs_with(&[
|
||||
@@ -265,8 +251,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Non-heads/tags refs are filtered ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn skips_non_heads_or_tags_refs() {
|
||||
let refs = refs_with(&[
|
||||
@@ -303,8 +287,6 @@ mod tests {
|
||||
assert!(first_tag(&ev, "refs/pull/1/head").is_none());
|
||||
}
|
||||
|
||||
// ── OID validation: SHA-1 and SHA-256 ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn accepts_sha1_and_sha256_oids() {
|
||||
let sha1 = "1111111111111111111111111111111111111111"; // 40 hex
|
||||
@@ -351,8 +333,6 @@ mod tests {
|
||||
assert!(first_tag(&ev, "refs/heads/ok").is_some());
|
||||
}
|
||||
|
||||
// ── Ref name validation ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_ref_names() {
|
||||
let refs = refs_with(&[
|
||||
@@ -385,8 +365,6 @@ mod tests {
|
||||
assert_eq!(tags_with_kind(&ev, "refs/heads//double").len(), 0);
|
||||
}
|
||||
|
||||
// ── Actor pubkey errors ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_actor_pubkey() {
|
||||
let refs = refs_with(&[]);
|
||||
@@ -400,8 +378,6 @@ mod tests {
|
||||
assert!(matches!(err, BuildError::InvalidActor(_)));
|
||||
}
|
||||
|
||||
// ── d-tag matches kind:30617 identifier (NOT <repo>.git) ─────────────────
|
||||
|
||||
#[test]
|
||||
fn d_tag_is_repo_id_not_repo_dot_git() {
|
||||
let refs = refs_with(&[]);
|
||||
|
||||
@@ -46,8 +46,6 @@ use buzz_db::EventQuery;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum age of a hook callback (seconds). Push is synchronous so 30s is generous.
|
||||
const MAX_CALLBACK_AGE_SECS: u64 = 30;
|
||||
|
||||
@@ -110,8 +108,6 @@ impl From<Denial> for DenialResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC Verification ────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the canonical HMAC payload.
|
||||
///
|
||||
/// Format (length-prefixed, `|`-separated, structurally unambiguous):
|
||||
@@ -165,8 +161,6 @@ fn verify_hmac(secret: &[u8], req: &HookCallbackRequest) -> bool {
|
||||
expected.ct_eq(&provided).into()
|
||||
}
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `POST /internal/git/policy` — pre-receive hook callback.
|
||||
///
|
||||
/// Fail-closed: ANY error returns 403. The hook script treats non-200 as deny.
|
||||
@@ -383,8 +377,6 @@ pub async fn hook_policy_check(
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC Generation (for the relay to pass to the hook) ──────────────────────
|
||||
|
||||
/// Generate the HMAC signature for a hook callback payload.
|
||||
///
|
||||
/// Called by the relay when setting up the pre-receive hook environment.
|
||||
@@ -408,8 +400,6 @@ pub fn generate_hook_hmac(
|
||||
hex::encode(mac_bytes)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -34,15 +34,11 @@ use super::hydrate::{
|
||||
use super::manifest_event::{build_ref_state_event, RefStateInputs};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Timeouts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`).
|
||||
const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
/// Timeout for pack operations (upload-pack, receive-pack) — large repos need time.
|
||||
const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
// ── NIP-98 Auth Extractor ────────────────────────────────────────────────────
|
||||
|
||||
/// NIP-98 auth extractor for git routes.
|
||||
///
|
||||
/// Validates the `Authorization: Nostr <base64>` header before the request body
|
||||
@@ -193,8 +189,6 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repo Id Validation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Validate URL `(owner, repo)` parameters and return the canonical repo
|
||||
/// id (= `repo` with any `.git` suffix stripped).
|
||||
///
|
||||
@@ -283,8 +277,6 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── Route Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
/// Query parameters for the `info/refs` endpoint.
|
||||
pub struct InfoRefsQuery {
|
||||
@@ -298,8 +290,6 @@ pub struct GitRepoParams {
|
||||
repo: String,
|
||||
}
|
||||
|
||||
// ── Manifest-Driven Advertisement (Track C) ──────────────────────────────────
|
||||
|
||||
/// Longest refname the fast path will emit. `is_safe_refname` enforces an
|
||||
/// alphabet but no length bound; `pkt_line` encodes its payload length in a
|
||||
/// 4-hex prefix that overflows past `0xffff`. Git's own refname limits sit far
|
||||
@@ -744,8 +734,6 @@ pub async fn receive_pack(
|
||||
Ok(finalize_push(&state, ctx).await)
|
||||
}
|
||||
|
||||
// ── Subprocess Runner ────────────────────────────────────────────────────────
|
||||
|
||||
/// Buffered output of a `git --stateless-rpc` subprocess.
|
||||
///
|
||||
/// The handler holds this as an owned value between subprocess completion
|
||||
@@ -839,8 +827,6 @@ async fn run_git_at(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Read-Path Streaming Runner (Track A) ─────────────────────────────────────
|
||||
|
||||
/// Keeps the git subprocess and its hydrated workspace alive for exactly as
|
||||
/// long as the response body is being streamed.
|
||||
///
|
||||
@@ -980,8 +966,6 @@ fn build_git_response(service: &str, output: PackOutput) -> Response {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Post-Push Fence ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-push state captured between subprocess completion and response
|
||||
/// construction. Constructing a `PushContext` is the only path from a
|
||||
/// push subprocess to a 2xx push response (see [`finalize_push`]) — the
|
||||
@@ -1161,8 +1145,6 @@ async fn finalize_push(state: &Arc<AppState>, ctx: PushContext) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
// ── Router Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the git sub-router with its own body limit.
|
||||
///
|
||||
/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size.
|
||||
|
||||
@@ -22,8 +22,6 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Axum extractor that validates Blossom auth + API token scopes from headers
|
||||
/// BEFORE the request body is read. This prevents unauthenticated clients from
|
||||
/// forcing the server to buffer up to 50MB of body data.
|
||||
@@ -219,8 +217,6 @@ pub async fn upload_blob(
|
||||
Ok(Json(descriptor))
|
||||
}
|
||||
|
||||
// ── Serve ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether a path-segment extension is a safe token.
|
||||
///
|
||||
/// The sidecar's `ext` field is the *authoritative* extension — the serve and
|
||||
@@ -536,8 +532,6 @@ pub async fn head_blob(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the S3 key from a URL path segment.
|
||||
///
|
||||
/// - `sha256.ext` → used as-is (already validated by `validate_media_path`)
|
||||
@@ -753,8 +747,6 @@ mod tests {
|
||||
assert!(validate_media_path("").is_err());
|
||||
}
|
||||
|
||||
// ── Range request parsing ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_parse_byte_range_basic() {
|
||||
assert_eq!(parse_byte_range("bytes=0-499", 1000), Some((0, 499)));
|
||||
|
||||
@@ -9,8 +9,6 @@ pub mod nip05;
|
||||
// Re-export imeta helpers used by ingest pipeline.
|
||||
pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs};
|
||||
|
||||
// ── Shared helpers (used by media.rs, bridge.rs) ──────────────────────────────
|
||||
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
|
||||
/// Standard error envelope.
|
||||
|
||||
@@ -52,8 +52,6 @@ const MAX_MISSED_PONGS: u8 = 3;
|
||||
/// Auth timeout.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
// ── Route handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// WebSocket upgrade handler for `/huddle/:channel_id/audio`.
|
||||
pub async fn ws_audio_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -63,8 +61,6 @@ pub async fn ws_audio_handler(
|
||||
ws.on_upgrade(move |socket| handle_audio_connection(socket, state, channel_id))
|
||||
}
|
||||
|
||||
// ── Auth message shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// Highest huddle audio protocol version this relay understands. Clients are
|
||||
/// allowed to negotiate any version in `1..=CURRENT_PROTOCOL_VERSION`; older
|
||||
/// versions stay supported indefinitely for staged rollouts.
|
||||
@@ -88,12 +84,9 @@ fn default_protocol_version() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
// ── Core connection lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channel_id: Uuid) {
|
||||
let (mut ws_send, mut ws_recv) = socket.split();
|
||||
|
||||
// ── Step 1: send challenge ────────────────────────────────────────────────
|
||||
let challenge = generate_challenge();
|
||||
let challenge_msg =
|
||||
serde_json::json!({"type": "challenge", "challenge": challenge}).to_string();
|
||||
@@ -105,7 +98,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 2: await auth (5s timeout) ──────────────────────────────────────
|
||||
let auth_result = tokio::time::timeout(AUTH_TIMEOUT, async {
|
||||
while let Some(Ok(msg)) = ws_recv.next().await {
|
||||
if let WsMessage::Text(text) = msg {
|
||||
@@ -160,7 +152,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
let parent_channel_id = auth_msg.parent_channel_id;
|
||||
|
||||
// ── Relay membership gate (with NIP-OA fallback) ────────────────────────────
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
pubkey.as_bytes(),
|
||||
@@ -180,7 +171,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 3: membership check / auto-add ───────────────────────────────────
|
||||
if let Err(e) = ensure_membership(&state, channel_id, &pubkey_bytes, parent_channel_id).await {
|
||||
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}");
|
||||
let _ = ws_send
|
||||
@@ -193,7 +183,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 4: join room ─────────────────────────────────────────────────────
|
||||
let room = state.audio_rooms.get_or_create(channel_id);
|
||||
|
||||
// Re-check archived status after obtaining the room. This closes the
|
||||
@@ -309,7 +298,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
"audio peer joined"
|
||||
);
|
||||
|
||||
// ── Step 5: broadcast joined + send welcome ───────────────────────────────
|
||||
let peers_snapshot: Vec<serde_json::Value> = room
|
||||
.peer_pubkeys()
|
||||
.into_iter()
|
||||
@@ -326,7 +314,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
|
||||
room.broadcast_control(joined_msg);
|
||||
|
||||
// ── Step 6: emit kind:48101 (PARTICIPANT_JOINED) ──────────────────────────
|
||||
let parent_id_for_event = parent_channel_id.unwrap_or(channel_id);
|
||||
emit_participant_event(
|
||||
&state,
|
||||
@@ -337,7 +324,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── Step 7: spawn send + heartbeat loops ──────────────────────────────────
|
||||
let cancel = CancellationToken::new();
|
||||
let missed_pongs = Arc::new(AtomicU8::new(0));
|
||||
|
||||
@@ -353,7 +339,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
let hb_missed = Arc::clone(&missed_pongs);
|
||||
let heartbeat_task = tokio::spawn(heartbeat_loop(ctrl_tx.clone(), hb_missed, hb_cancel));
|
||||
|
||||
// ── Step 8: audio forward loop (room channels → WS send channels) ────────
|
||||
let fwd_cancel = cancel.child_token();
|
||||
let forward_task = tokio::spawn(audio_forward_loop(
|
||||
audio_rx,
|
||||
@@ -363,7 +348,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
fwd_cancel,
|
||||
));
|
||||
|
||||
// ── Step 9: recv loop (blocks until disconnect) ───────────────────────────
|
||||
recv_loop(
|
||||
ws_recv,
|
||||
Arc::clone(&room),
|
||||
@@ -375,7 +359,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
cancel.cancel();
|
||||
let _ = send_task.await;
|
||||
let _ = heartbeat_task.await;
|
||||
@@ -439,8 +422,6 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recv loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn recv_loop(
|
||||
mut ws_recv: futures_util::stream::SplitStream<WebSocket>,
|
||||
room: Arc<crate::audio::room::Room>,
|
||||
@@ -540,8 +521,6 @@ async fn recv_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Outbound send loop with control-frame priority (matches connection.rs pattern).
|
||||
///
|
||||
/// Control frames (Ping, Pong, Close, control JSON) are drained first on every
|
||||
@@ -576,7 +555,6 @@ async fn send_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio forward loop ────────────────────────────────────────────────────────
|
||||
// Bridges the room's mpsc channel to the WS send channel.
|
||||
|
||||
/// Bridges room per-peer channels → WS send channels.
|
||||
@@ -615,8 +593,6 @@ async fn audio_forward_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn heartbeat_loop(
|
||||
ws_tx: mpsc::Sender<WsMessage>,
|
||||
missed_pongs: Arc<AtomicU8>,
|
||||
@@ -643,8 +619,6 @@ async fn heartbeat_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Membership helper ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn ensure_membership(
|
||||
state: &AppState,
|
||||
channel_id: Uuid,
|
||||
@@ -714,8 +688,6 @@ async fn ensure_membership(
|
||||
Err("not a member".into())
|
||||
}
|
||||
|
||||
// ── Lifecycle event helper ────────────────────────────────────────────────────
|
||||
|
||||
async fn emit_participant_event(
|
||||
state: &AppState,
|
||||
kind: Kind,
|
||||
|
||||
@@ -109,7 +109,6 @@ pub struct Config {
|
||||
/// 60 seconds after the last message.
|
||||
pub ephemeral_ttl_override: Option<i32>,
|
||||
|
||||
// ── Git server configuration ─────────────────────────────────────────────
|
||||
/// Root directory for the relay's local git state. No per-repo bare repos
|
||||
/// live here — runtime reads/writes hydrate ephemeral repos from object
|
||||
/// storage. Holds only the name-reservation index at `{git_repo_path}/.names/`.
|
||||
@@ -124,7 +123,6 @@ pub struct Config {
|
||||
/// Used to authenticate internal policy endpoint requests.
|
||||
pub git_hook_hmac_secret: String,
|
||||
|
||||
// ── Web UI serving ────────────────────────────────────────────────────────
|
||||
/// Optional path to the web UI `dist/` directory.
|
||||
/// When set, the relay serves the SPA from this directory for browser requests.
|
||||
/// When unset, no static file serving happens (relay behaves as before).
|
||||
|
||||
@@ -150,8 +150,6 @@ async fn persist_command_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag extraction helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract all `p` tag values (hex pubkeys) from an event.
|
||||
fn extract_p_tags(event: &Event) -> Vec<String> {
|
||||
event
|
||||
@@ -228,8 +226,6 @@ fn compute_definition_hash(json_str: &str) -> Vec<u8> {
|
||||
Sha256::digest(json_str.as_bytes()).to_vec()
|
||||
}
|
||||
|
||||
// ── DM commands (41010–41012) ────────────────────────────────────────────────
|
||||
|
||||
async fn handle_dm_open(
|
||||
state: &Arc<AppState>,
|
||||
event: &Event,
|
||||
@@ -553,8 +549,6 @@ async fn handle_dm_hide(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Workflow commands ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_workflow_def(
|
||||
state: &Arc<AppState>,
|
||||
event: &Event,
|
||||
@@ -792,8 +786,6 @@ async fn handle_workflow_trigger(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Approval commands ────────────────────────────────────────────────────────
|
||||
|
||||
/// Enforce the approver_spec field against the requesting pubkey.
|
||||
///
|
||||
/// Accepted specs:
|
||||
@@ -1067,8 +1059,6 @@ async fn handle_approval_deny(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Approval resume helper ───────────────────────────────────────────────────
|
||||
|
||||
/// Resume a suspended workflow run after an approval gate has been granted.
|
||||
async fn resume_workflow_after_approval(
|
||||
engine: Arc<buzz_workflow::WorkflowEngine>,
|
||||
|
||||
@@ -360,7 +360,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
debug!(event_id = %event_id_hex, kind = kind_u32, "EVENT");
|
||||
metrics::counter!("buzz_events_received_total", "kind" => kind_str.clone()).increment(1);
|
||||
|
||||
// ── Extract auth from WS connection state ────────────────────────────
|
||||
let (conn_id, pubkey_bytes, auth_pubkey, scopes, channel_ids) = {
|
||||
let auth = conn.auth_state.read().await;
|
||||
match &*auth {
|
||||
@@ -383,7 +382,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
}
|
||||
};
|
||||
|
||||
// ── Pubkey / auth identity match (all events) ─────────────────────
|
||||
// Must run before both ephemeral and persistent branches. Persistent
|
||||
// events get a second check inside ingest_event() (step 3), but
|
||||
// ephemeral events bypass the pipeline entirely.
|
||||
@@ -399,7 +397,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Blocked kinds (both ephemeral and persistent) ─────────────────
|
||||
if kind_u32 == buzz_core::kind::KIND_AUTH {
|
||||
reject("invalid");
|
||||
conn.send(RelayMessage::ok(
|
||||
@@ -410,7 +407,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Agent observer frames are owner-scoped, encrypted, and never stored ──
|
||||
if kind_u32 == KIND_AGENT_OBSERVER_FRAME {
|
||||
if !scopes.is_empty()
|
||||
&& !scopes.contains(&buzz_auth::Scope::MessagesWrite)
|
||||
@@ -428,7 +424,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ephemeral events are WS-only (never stored) ──────────────────────
|
||||
// Scope enforcement for ephemeral kinds: require MessagesWrite or
|
||||
// ProxySubmit. Persistent events skip this gate and rely on
|
||||
// ingest_event()'s per-kind scope allowlist instead, so a token with
|
||||
@@ -475,7 +470,6 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Persistent events → ingest pipeline ──────────────────────────────
|
||||
let ingest_auth = IngestAuth::Nip42 {
|
||||
pubkey: auth_pubkey,
|
||||
scopes,
|
||||
|
||||
@@ -329,8 +329,6 @@ pub async fn verify_imeta_blobs(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether a string is a well-formed `type/subtype` MIME token.
|
||||
///
|
||||
/// Structural check only — does not enforce a known type. The authoritative
|
||||
|
||||
@@ -41,8 +41,6 @@ use crate::state::AppState;
|
||||
|
||||
use super::event::dispatch_persistent_event;
|
||||
|
||||
// ── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// How the HTTP caller authenticated (for [`IngestAuth::Http`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HttpAuthMethod {
|
||||
@@ -145,8 +143,6 @@ pub enum IngestError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// ── Per-kind scope allowlist ─────────────────────────────────────────────────
|
||||
|
||||
/// Determine the required scope for a given event kind.
|
||||
///
|
||||
/// Returns `Err` for unknown kinds — the relay rejects them.
|
||||
@@ -246,8 +242,6 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel resolution helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Extract a channel UUID from the `"h"` NIP-29 group tag.
|
||||
pub(crate) fn extract_channel_id(event: &Event) -> Option<Uuid> {
|
||||
for tag in event.tags.iter() {
|
||||
@@ -441,8 +435,6 @@ pub(crate) async fn check_channel_membership(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token channel access ─────────────────────────────────────────────────────
|
||||
|
||||
fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(), String> {
|
||||
if let Some(allowed) = auth.channel_ids() {
|
||||
if !allowed.contains(&channel_id) {
|
||||
@@ -452,8 +444,6 @@ fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-10 thread resolution ─────────────────────────────────────────────────
|
||||
|
||||
/// Owned thread metadata for the DB insert.
|
||||
pub(crate) struct ThreadMetadataOwned {
|
||||
pub event_id: Vec<u8>,
|
||||
@@ -630,8 +620,6 @@ pub(crate) async fn resolve_nip10_thread_meta(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── New validations (Phase 0a additions) ─────────────────────────────────────
|
||||
|
||||
/// Count all `e` tags regardless of content validity.
|
||||
fn count_e_tags(event: &Event) -> usize {
|
||||
event
|
||||
@@ -1122,8 +1110,6 @@ fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── The pipeline ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ingest a signed Nostr event through the full validation pipeline.
|
||||
///
|
||||
/// Shared by WebSocket and HTTP transports. The caller constructs [`IngestAuth`]
|
||||
@@ -1138,7 +1124,6 @@ pub async fn ingest_event(
|
||||
let kind_u32 = event_kind_u32(&event);
|
||||
debug!(event_id = %event_id_hex, kind = kind_u32, "ingest_event");
|
||||
|
||||
// ── 1. Blocked kinds ─────────────────────────────────────────────────
|
||||
if kind_u32 == KIND_AUTH {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: AUTH events cannot be submitted".into(),
|
||||
@@ -1150,19 +1135,16 @@ pub async fn ingest_event(
|
||||
));
|
||||
}
|
||||
|
||||
// ── 1b. HTTP-only kind gate ─────────────────────────────────────────
|
||||
if auth.is_http() && (kind_u32 == KIND_GIFT_WRAP || kind_u32 == KIND_PRESENCE_UPDATE) {
|
||||
return Err(IngestError::Rejected(format!(
|
||||
"invalid: kind {kind_u32} is only accepted via WebSocket"
|
||||
)));
|
||||
}
|
||||
|
||||
// ── 1c. Reject relay-only kinds from external submission ─────────────
|
||||
if buzz_core::kind::is_relay_only_kind(kind_u32) {
|
||||
return Err(IngestError::Rejected("restricted: relay-only kind".into()));
|
||||
}
|
||||
|
||||
// ── 2. Signature verification ────────────────────────────────────────
|
||||
let event_clone = event.clone();
|
||||
let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await;
|
||||
match verify_result {
|
||||
@@ -1178,7 +1160,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b. Timestamp sanity ─────────────────────────────────────────────
|
||||
// Skip for proxy:submit — proxy-translated events preserve upstream
|
||||
// created_at timestamps which may be historical (backfill/replay).
|
||||
if !auth.has_proxy_scope() {
|
||||
@@ -1192,7 +1173,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2c. Content size guard ───────────────────────────────────────────
|
||||
const MAX_EVENT_CONTENT_BYTES: usize = 256 * 1024; // 256 KB
|
||||
if event.content.len() > MAX_EVENT_CONTENT_BYTES {
|
||||
return Err(IngestError::Rejected(format!(
|
||||
@@ -1202,7 +1182,6 @@ pub async fn ingest_event(
|
||||
)));
|
||||
}
|
||||
|
||||
// ── 3. Pubkey match ──────────────────────────────────────────────────
|
||||
let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP;
|
||||
if event.pubkey != *auth.pubkey() && !auth.has_proxy_scope() && !is_gift_wrap {
|
||||
return Err(IngestError::AuthFailed(
|
||||
@@ -1210,7 +1189,6 @@ pub async fn ingest_event(
|
||||
));
|
||||
}
|
||||
|
||||
// ── 4. Per-kind scope allowlist ──────────────────────────────────────
|
||||
let required = match required_scope_for_kind(kind_u32, &event) {
|
||||
Ok(scope) => scope,
|
||||
Err(msg) => return Err(IngestError::Rejected(msg.into())),
|
||||
@@ -1245,14 +1223,12 @@ pub async fn ingest_event(
|
||||
)));
|
||||
}
|
||||
|
||||
// ── 4b. Route command kinds to command executor ──────────────────────
|
||||
// Command kinds are routed AFTER signature verification, timestamp check,
|
||||
// pubkey/auth match, and scope validation — never before.
|
||||
if buzz_core::kind::is_command_kind(kind_u32) {
|
||||
return super::command_executor::handle_command(state, event, auth).await;
|
||||
}
|
||||
|
||||
// ── 5. Channel resolution ────────────────────────────────────────────
|
||||
let mut channel_id = if kind_u32 == KIND_REACTION {
|
||||
match derive_reaction_channel(&state.db, &event).await {
|
||||
ReactionChannelResult::Channel(ch_id) => Some(ch_id),
|
||||
@@ -1314,19 +1290,16 @@ pub async fn ingest_event(
|
||||
extract_channel_id(&event)
|
||||
};
|
||||
|
||||
// ── 5b. Global-only kinds ignore h-tags ─────────────────────────────
|
||||
if is_global_only_kind(kind_u32) {
|
||||
channel_id = None;
|
||||
}
|
||||
|
||||
// ── 6. h-tag requirement ─────────────────────────────────────────────
|
||||
if requires_h_channel_scope(kind_u32) && channel_id.is_none() {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: channel-scoped events must include an h tag".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── 7. Token channel access ──────────────────────────────────────────
|
||||
if let Some(ch_id) = channel_id {
|
||||
check_token_channel_access(&auth, ch_id).map_err(IngestError::AuthFailed)?;
|
||||
} else if auth.channel_ids().is_some() {
|
||||
@@ -1339,7 +1312,6 @@ pub async fn ingest_event(
|
||||
));
|
||||
}
|
||||
|
||||
// ── 8. Membership check ──────────────────────────────────────────────
|
||||
let pubkey_bytes = auth.pubkey().to_bytes().to_vec();
|
||||
if let Some(ch_id) = channel_id {
|
||||
// kind:9021 (join) doesn't require prior membership.
|
||||
@@ -1354,7 +1326,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9a. Relay admin commands (kinds 9030–9032) ───────────────────────
|
||||
// Handled directly — these mutate relay_members and do NOT get stored.
|
||||
if is_relay_admin_kind(event.kind.as_u16() as u32) {
|
||||
crate::handlers::relay_admin::handle_relay_admin_event(state, &event)
|
||||
@@ -1367,7 +1338,6 @@ pub async fn ingest_event(
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9b. NIP-43 leave request (kind 28936) ────────────────────────────
|
||||
// Handled directly — removes the sender from relay_members. NOT stored.
|
||||
if kind_u32 == KIND_NIP43_LEAVE_REQUEST {
|
||||
if !state.config.require_relay_membership {
|
||||
@@ -1451,14 +1421,12 @@ pub async fn ingest_event(
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Admin validation (kinds 9000–9022) ────────────────────────────
|
||||
if crate::handlers::side_effects::is_admin_kind(kind_u32) {
|
||||
crate::handlers::side_effects::validate_admin_event(kind_u32, &event, state)
|
||||
.await
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 9c. NIP-IA identity archive requests (kinds 9035/9036) ───────────
|
||||
// Processed here (verify consent, mutate archived_identities, emit the
|
||||
// relay-signed 8002/8003 delta + 13535 snapshot), then — unlike the
|
||||
// NIP-43 admin commands above — the request itself falls through to normal
|
||||
@@ -1469,14 +1437,12 @@ pub async fn ingest_event(
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 10. Standard deletion validation (kind:5) ────────────────────────
|
||||
if kind_u32 == KIND_DELETION {
|
||||
crate::handlers::side_effects::validate_standard_deletion_event(&event, state)
|
||||
.await
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 11. Archived channel check ───────────────────────────────────────
|
||||
if let Some(ch_id) = channel_id {
|
||||
// Allow kind:9002 with archived=false (unarchive operation)
|
||||
let is_unarchive = kind_u32 == KIND_NIP29_EDIT_METADATA
|
||||
@@ -1494,7 +1460,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 12. Single-target enforcement (kind:9005, kind:5) ────────────────
|
||||
// NIP-09: kind:5 may reference targets via `e` tag (regular events) OR
|
||||
// `a` tag (addressable/parameterized-replaceable events like kind:30620).
|
||||
if kind_u32 == KIND_NIP29_DELETE_EVENT || kind_u32 == KIND_DELETION {
|
||||
@@ -1511,38 +1476,32 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 13. Edit ownership (kind:40003) ──────────────────────────────────
|
||||
if kind_u32 == KIND_STREAM_MESSAGE_EDIT {
|
||||
validate_edit_ownership(&event, state)
|
||||
.await
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 14. Forum vote target-kind (kind:45002) ──────────────────────────
|
||||
if kind_u32 == KIND_FORUM_VOTE {
|
||||
validate_forum_vote_target(&event, state)
|
||||
.await
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 15. Diff validation (kind:40008) ─────────────────────────────────
|
||||
if kind_u32 == KIND_STREAM_MESSAGE_DIFF {
|
||||
validate_diff_event(&event).map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 15a. Agent engram envelope (kind:30174) ──────────────────────────
|
||||
if kind_u32 == KIND_AGENT_ENGRAM {
|
||||
validate_engram_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 15b. Event reminder schedule tags (kind:30300) ───────────────────
|
||||
if kind_u32 == KIND_EVENT_REMINDER {
|
||||
validate_event_reminder(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 15c. Persona envelope (kind:30175) ──────────────────────────────
|
||||
if kind_u32 == KIND_PERSONA {
|
||||
validate_persona_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
@@ -1551,7 +1510,6 @@ pub async fn ingest_event(
|
||||
// Track pre-created channel UUID for compensation on insert failure.
|
||||
let mut pre_created_channel: Option<Uuid> = None;
|
||||
|
||||
// ── 16. kind:9007 UUID dedup (create channel with client UUID) ───────
|
||||
if kind_u32 == KIND_NIP29_CREATE_GROUP {
|
||||
// Validate name tag is present and non-empty before any DB work.
|
||||
let create_name = event.tags.iter().find_map(|t| {
|
||||
@@ -1643,7 +1601,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 17. kind:9021 open-only check ────────────────────────────────────
|
||||
if kind_u32 == KIND_NIP29_JOIN_REQUEST {
|
||||
// A join without an h-tag is meaningless — reject early.
|
||||
if channel_id.is_none() {
|
||||
@@ -1666,7 +1623,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 18. imeta tag validation ─────────────────────────────────────────
|
||||
let imeta_tags: Vec<Vec<String>> = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -1681,7 +1637,6 @@ pub async fn ingest_event(
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 19. NIP-10 thread resolution ─────────────────────────────────────
|
||||
let thread_meta = if requires_h_channel_scope(kind_u32) {
|
||||
if let Some(ch_id) = channel_id {
|
||||
resolve_nip10_thread_meta(&event, ch_id, state)
|
||||
@@ -1694,8 +1649,6 @@ pub async fn ingest_event(
|
||||
None
|
||||
};
|
||||
|
||||
// ── 20. DB insert ────────────────────────────────────────────────────
|
||||
|
||||
// Pre-validate kind:0 content before storage so we don't store an event
|
||||
// whose profile sync will silently fail in the side-effect handler.
|
||||
if kind_u32 == KIND_PROFILE
|
||||
@@ -1706,7 +1659,6 @@ pub async fn ingest_event(
|
||||
));
|
||||
}
|
||||
|
||||
// ── 20a. Reaction dedup (kind:7) — before storage ────────────────────
|
||||
// Resolve target event, insert the reaction row (dedup via ON CONFLICT),
|
||||
// store the event, then backfill the reaction_event_id. If the event insert
|
||||
// fails, compensate by removing the reaction row so state stays consistent.
|
||||
@@ -1894,7 +1846,6 @@ pub async fn ingest_event(
|
||||
});
|
||||
}
|
||||
|
||||
// ── 20b. Bump ephemeral channel TTL deadline ──────────────────────
|
||||
// Any successfully stored channel-scoped event keeps the channel alive.
|
||||
// Skip kind:9007 (create) — the deadline was just set during creation.
|
||||
if let Some(ch_id) = channel_id {
|
||||
@@ -1905,7 +1856,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 21. Side effects ─────────────────────────────────────────────────
|
||||
if crate::handlers::side_effects::is_side_effect_kind(kind_u32) {
|
||||
if let Err(e) =
|
||||
crate::handlers::side_effects::handle_side_effects(kind_u32, &event, state).await
|
||||
@@ -1914,7 +1864,6 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 22. Fan-out ──────────────────────────────────────────────────────
|
||||
let pubkey_hex = auth.pubkey().to_hex();
|
||||
dispatch_persistent_event(state, &stored_event, kind_u32, &pubkey_hex).await;
|
||||
|
||||
@@ -2309,8 +2258,6 @@ mod tests {
|
||||
assert!(validate_diff_event(&event).is_err());
|
||||
}
|
||||
|
||||
// ── Test helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn make_dummy_event() -> Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
|
||||
@@ -2349,8 +2296,6 @@ mod tests {
|
||||
assert_eq!(count_e_tags(&event), 1);
|
||||
}
|
||||
|
||||
// ── NIP-AE envelope validation ───────────────────────────────────────
|
||||
|
||||
fn make_engram(tags: &[&[&str]], content: &str) -> Event {
|
||||
make_event_with_tags(KIND_AGENT_ENGRAM, content, tags)
|
||||
}
|
||||
@@ -2492,8 +2437,6 @@ mod tests {
|
||||
assert!(err.contains("base64"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── NIP-ER not_before validation ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn not_before_accepts_zero() {
|
||||
assert_eq!(validate_not_before("0"), Ok(0));
|
||||
@@ -2553,8 +2496,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── NIP-ER reminder envelope validation ──────────────────────────────
|
||||
|
||||
fn make_reminder(tags: &[&[&str]]) -> Event {
|
||||
make_event_with_tags(KIND_EVENT_REMINDER, "ciphertext", tags)
|
||||
}
|
||||
@@ -2693,8 +2634,6 @@ mod tests {
|
||||
assert_eq!(validate_event_reminder(&ev), Err("duplicate d tag"));
|
||||
}
|
||||
|
||||
// ── NIP-AP persona envelope validation ───────────────────────────────
|
||||
|
||||
fn make_persona(tags: &[&[&str]]) -> Event {
|
||||
make_event_with_tags(
|
||||
KIND_PERSONA,
|
||||
|
||||
@@ -430,7 +430,6 @@ mod tests {
|
||||
assert_eq!(r2.peer_endpoint_id, None);
|
||||
}
|
||||
|
||||
// ── Trust gate: membership_admits_mesh ──────────────────────────────────
|
||||
// This is the single pure predicate behind the requester, target, AND
|
||||
// reporter gates. v1 admits only direct relay members (or open relays);
|
||||
// NIP-OA-delegated (ViaOwner) and Denied are excluded, symmetrically.
|
||||
@@ -675,7 +674,6 @@ mod tests {
|
||||
assert!(target_rx.try_recv().is_err(), "target receives no event");
|
||||
}
|
||||
|
||||
// ── HTTP door (handle_mesh_event_http) ──────────────────────────────────
|
||||
// Regression coverage for the post-#879 transport: the desktop's Rust
|
||||
// coordinator publishes 24620/24621 via POST /events, which used to fall
|
||||
// into ingest_event's allowlist and 400 with "unknown event kind".
|
||||
|
||||
@@ -24,8 +24,6 @@ use crate::handlers::side_effects::{
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Tag extraction helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the hex pubkey from the first `p` tag, returning it as a `String`.
|
||||
fn extract_p_tag_hex(event: &Event) -> Option<String> {
|
||||
for tag in event.tags.iter() {
|
||||
@@ -53,8 +51,6 @@ fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── Public handler ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate and execute a relay admin command (kinds 9030–9032).
|
||||
///
|
||||
/// The handler:
|
||||
@@ -70,7 +66,6 @@ pub async fn handle_relay_admin_event(state: &Arc<AppState>, event: &Event) -> R
|
||||
let kind = event.kind.as_u16() as u32;
|
||||
let sender_hex = event.pubkey.to_hex();
|
||||
|
||||
// ── Replay protection: reject events outside ±120s of now ────────────
|
||||
// This mirrors the NIP-42 auth event freshness check and prevents replay
|
||||
// of captured admin commands. The window is intentionally tight — admin
|
||||
// events should be freshly signed.
|
||||
@@ -88,12 +83,10 @@ pub async fn handle_relay_admin_event(state: &Arc<AppState>, event: &Event) -> R
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract target pubkey ─────────────────────────────────────────────
|
||||
let target_hex = extract_p_tag_hex(event)
|
||||
.ok_or_else(|| "missing or invalid p tag".to_string())?
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// ── Look up sender's relay role ───────────────────────────────────────
|
||||
let sender_member = state
|
||||
.db
|
||||
.get_relay_member(&sender_hex)
|
||||
@@ -105,7 +98,6 @@ pub async fn handle_relay_admin_event(state: &Arc<AppState>, event: &Event) -> R
|
||||
.map(|m| m.role.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// ── Dispatch by kind ──────────────────────────────────────────────────
|
||||
match kind {
|
||||
// kind:9030 — Add relay member
|
||||
k if k == RELAY_ADMIN_ADD_MEMBER => {
|
||||
@@ -300,8 +292,6 @@ mod tests {
|
||||
.expect("signing failed")
|
||||
}
|
||||
|
||||
// ── extract_p_tag_hex ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_valid_hex() {
|
||||
let hex = "a".repeat(64);
|
||||
@@ -343,8 +333,6 @@ mod tests {
|
||||
assert_eq!(extract_p_tag_hex(&event), None);
|
||||
}
|
||||
|
||||
// ── extract_tag_value ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_tag_value_found() {
|
||||
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
|
||||
|
||||
@@ -91,7 +91,6 @@ pub async fn handle_req(
|
||||
|
||||
let channel_id = extract_channel_id_from_filters(&filters);
|
||||
|
||||
// ── Channel access + stale-cache repair (BEFORE search & registration) ───
|
||||
// Confirm channel access up front so the repaired `accessible_channels`
|
||||
// vector reaches every downstream consumer: the NIP-50 search branch
|
||||
// below, subscription registration, historical delivery, and COUNT. A
|
||||
@@ -132,7 +131,6 @@ pub async fn handle_req(
|
||||
}
|
||||
}
|
||||
|
||||
// ── #p / engram gating for globally-stored sensitive kinds ───────────────
|
||||
// Applied BEFORE the NIP-50 search branch so that an authenticated member
|
||||
// cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated
|
||||
// kinds) to harvest indexed-but-globally-stored sensitive events. Search
|
||||
@@ -166,7 +164,6 @@ pub async fn handle_req(
|
||||
}
|
||||
}
|
||||
|
||||
// ── NIP-50 search: one-shot, no persistent subscription ──────────────────
|
||||
// Search filters hit Typesense and return historical hits, then EOSE.
|
||||
// They are not registered for fan-out. The sensitive-kind gates above
|
||||
// already ran, so an authed member cannot use search to bypass author/#p
|
||||
@@ -1227,8 +1224,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── NIP-AE engram read gating ────────────────────────────────────────
|
||||
|
||||
/// Three real x-only pubkeys (valid for `PublicKey::from_hex`). Distinct,
|
||||
/// so we can label them clearly in tests.
|
||||
fn three_pubkeys() -> (String, String, String) {
|
||||
@@ -1338,7 +1333,6 @@ mod tests {
|
||||
assert!(!engram_filters_authorized(&[f], &agent));
|
||||
}
|
||||
|
||||
// ── NIP-50 search bypass regressions ─────────────────────────────────
|
||||
// These filters are the shape an authenticated relay member would send
|
||||
// to try to harvest indexed engram envelopes via the search path. The
|
||||
// gate must reject them regardless of the presence of `search`.
|
||||
|
||||
@@ -689,7 +689,6 @@ pub async fn emit_group_discovery_events(
|
||||
let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes());
|
||||
let group_id = channel_id.to_string();
|
||||
|
||||
// ── kind:39000 group metadata ────────────────────────────────────────────
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &group_id])?];
|
||||
tags.push(Tag::parse(["name", &channel.name])?);
|
||||
@@ -752,7 +751,6 @@ pub async fn emit_group_discovery_events(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ── kind:39001 group admins ──────────────────────────────────────────────
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &group_id])?];
|
||||
for m in members
|
||||
@@ -772,7 +770,6 @@ pub async fn emit_group_discovery_events(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ── kind:39002 group members ─────────────────────────────────────────────
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &group_id])?];
|
||||
for m in &members {
|
||||
@@ -794,8 +791,6 @@ pub async fn emit_group_discovery_events(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Kind:10100 Agent Profile Handler ─────────────────────────────────────────
|
||||
|
||||
async fn handle_agent_profile(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)
|
||||
.map_err(|e| anyhow::anyhow!("kind:10100 content parse error: {e}"))?;
|
||||
@@ -816,8 +811,6 @@ async fn handle_agent_profile(event: &Event, state: &Arc<AppState>) -> anyhow::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-01 Kind:0 Handler ────────────────────────────────────────────────────
|
||||
|
||||
/// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table.
|
||||
async fn handle_kind0_profile(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)
|
||||
@@ -892,8 +885,6 @@ async fn handle_kind0_profile(event: &Event, state: &Arc<AppState>) -> anyhow::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-29 Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_put_user(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
let channel_id =
|
||||
extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?;
|
||||
@@ -1765,8 +1756,6 @@ async fn handle_standard_deletion_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tag Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract channel UUID from `h` tag (NIP-29 group ID).
|
||||
fn extract_h_tag_channel(event: &Event) -> Option<Uuid> {
|
||||
for tag in event.tags.iter() {
|
||||
@@ -1865,8 +1854,6 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── NIP-34: Git repository side effects ──────────────────────────────────────
|
||||
|
||||
/// Validate a git repo identifier (d-tag value from kind:30617).
|
||||
///
|
||||
/// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
|
||||
@@ -2147,8 +2134,6 @@ async fn emit_initial_ref_state(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-43 relay-level membership announcement events ────────────────────────
|
||||
|
||||
/// Publish a kind:13534 relay membership list event (NIP-43).
|
||||
///
|
||||
/// Queries all current relay members and emits a relay-signed, NIP-70-protected
|
||||
@@ -2314,8 +2299,6 @@ pub async fn reconcile_channel_events(state: &Arc<AppState>) -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-IA relay-level identity archive announcement events ──────────────────
|
||||
|
||||
/// Publish a kind:13535 archived identities list event (NIP-IA).
|
||||
///
|
||||
/// Queries all current archived identities and emits a relay-signed,
|
||||
|
||||
@@ -48,7 +48,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
"Config loaded"
|
||||
);
|
||||
|
||||
// ── Metrics recorder (Prometheus exporter on :9102) ──────────────────────
|
||||
relay_metrics::install(config.metrics_port);
|
||||
info!(
|
||||
port = config.metrics_port,
|
||||
@@ -550,7 +549,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
serve(router, health_router, Arc::clone(&state)).await?;
|
||||
|
||||
// ── Drain audit queue ────────────────────────────────────────────────────
|
||||
// Signal the audit worker to stop accepting, flush buffered entries, and
|
||||
// exit. Uses a CancellationToken so it works regardless of how many
|
||||
// Arc<AppState> clones are still alive in background tasks.
|
||||
@@ -582,7 +580,6 @@ async fn serve(
|
||||
) -> anyhow::Result<()> {
|
||||
let config = &state.config;
|
||||
|
||||
// ── Health listener (port 8080) ──────────────────────────────────────────
|
||||
let health_listener = tokio::net::TcpListener::bind(("0.0.0.0", config.health_port))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?;
|
||||
@@ -591,7 +588,6 @@ async fn serve(
|
||||
axum::serve(health_listener, health_router).await.ok();
|
||||
});
|
||||
|
||||
// ── Shutdown coordination ────────────────────────────────────────────────
|
||||
let (shutdown_tx, _) = tokio::sync::watch::channel(false);
|
||||
let shutdown_flag = Arc::clone(&state.shutting_down);
|
||||
let tx = shutdown_tx.clone();
|
||||
@@ -609,13 +605,11 @@ async fn serve(
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// ── App listener (TCP) ───────────────────────────────────────────────────
|
||||
let tcp_listener = tokio::net::TcpListener::bind(&config.bind_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to bind {}: {e}", config.bind_addr))?;
|
||||
info!(addr = %config.bind_addr, "buzz-relay TCP listening");
|
||||
|
||||
// ── App listener (UDS, optional) ─────────────────────────────────────────
|
||||
#[cfg(unix)]
|
||||
if let Some(ref uds_path) = config.uds_path {
|
||||
use std::os::unix::fs::FileTypeExt as _;
|
||||
|
||||
@@ -229,8 +229,6 @@ mod tests {
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
// ── ClientMessage parsing — table-driven ─────────────────────────────
|
||||
|
||||
// Type alias to avoid clippy::type_complexity warning on the test case table.
|
||||
// The tuple holds: raw JSON string + a boxed checker closure.
|
||||
type ParseCase<'a> = (&'a str, Box<dyn Fn(ClientMessage)>);
|
||||
@@ -373,8 +371,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── RelayMessage formatting — table-driven ────────────────────────────
|
||||
|
||||
// Type alias to avoid clippy::type_complexity warning on the format test table.
|
||||
type FormatCase<'a> = (&'a str, Box<dyn Fn()>);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ use crate::state::AppState;
|
||||
/// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom),
|
||||
/// git (smart HTTP), NIP-05, and health probes.
|
||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
// ── Media routes: body limit covers both images and video ────────────────
|
||||
let media_body_limit = state
|
||||
.config
|
||||
.media
|
||||
@@ -44,13 +43,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.layer(RequestBodyLimitLayer::new(media_body_limit))
|
||||
.with_state(state.clone());
|
||||
|
||||
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
|
||||
let git_router = api::git::git_router(state.clone());
|
||||
|
||||
// ── Internal git policy route (pre-receive hook callback) ────────────────
|
||||
let git_policy_router = api::git::git_policy_router(state.clone());
|
||||
|
||||
// ── All other routes: 1 MB body limit ────────────────────────────────────
|
||||
let api_router = Router::new()
|
||||
// WebSocket + NIP-11
|
||||
.route("/", get(nip11_or_ws_handler))
|
||||
|
||||
@@ -628,8 +628,6 @@ impl std::fmt::Debug for AppState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -23,8 +23,6 @@ use crate::{
|
||||
ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection,
|
||||
};
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a tag slice, mapping errors to `SdkError::InvalidTag`.
|
||||
fn tag(parts: &[&str]) -> Result<Tag, SdkError> {
|
||||
Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))
|
||||
@@ -207,8 +205,6 @@ fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), Sdk
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Builder 1: build_message ─────────────────────────────────────────────────
|
||||
|
||||
/// Build a stream message (kind 9).
|
||||
///
|
||||
/// - `channel_id`: target channel UUID
|
||||
@@ -238,8 +234,6 @@ pub fn build_message(
|
||||
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder: build_agent_observer_frame ─────────────────────────────────────
|
||||
|
||||
/// Build an encrypted agent observer frame (kind 24200).
|
||||
///
|
||||
/// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only
|
||||
@@ -277,8 +271,6 @@ pub fn build_agent_observer_frame(
|
||||
.tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 2: build_forum_post ───────────────────────────────────────────────
|
||||
|
||||
/// Build a forum post thread root (kind 45001).
|
||||
pub fn build_forum_post(
|
||||
channel_id: Uuid,
|
||||
@@ -293,8 +285,6 @@ pub fn build_forum_post(
|
||||
Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 3: build_forum_comment ───────────────────────────────────────────
|
||||
|
||||
/// Build a forum comment reply (kind 45003).
|
||||
pub fn build_forum_comment(
|
||||
channel_id: Uuid,
|
||||
@@ -311,8 +301,6 @@ pub fn build_forum_comment(
|
||||
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 4: build_diff_message ────────────────────────────────────────────
|
||||
|
||||
/// Build a diff/patch message (kind 40008).
|
||||
pub fn build_diff_message(
|
||||
channel_id: Uuid,
|
||||
@@ -383,8 +371,6 @@ pub fn build_diff_message(
|
||||
Ok(EventBuilder::new(Kind::Custom(40008), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 5: build_edit ────────────────────────────────────────────────────
|
||||
|
||||
/// Build an edit event targeting an existing message (kind 40003).
|
||||
pub fn build_edit(
|
||||
channel_id: Uuid,
|
||||
@@ -399,8 +385,6 @@ pub fn build_edit(
|
||||
Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 6: build_delete_message ──────────────────────────────────────────
|
||||
|
||||
/// Build a Buzz-native delete event (kind 9005).
|
||||
pub fn build_delete_message(
|
||||
channel_id: Uuid,
|
||||
@@ -413,8 +397,6 @@ pub fn build_delete_message(
|
||||
Ok(EventBuilder::new(Kind::Custom(9005), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 7: build_delete_compat ───────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-09 deletion event (kind 5). The `h` tag is non-standard for
|
||||
/// NIP-09 but is required so channel-scoped subscriptions observe the delete.
|
||||
pub fn build_delete_compat(
|
||||
@@ -428,8 +410,6 @@ pub fn build_delete_compat(
|
||||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 8: build_vote ────────────────────────────────────────────────────
|
||||
|
||||
/// Build a forum vote event (kind 45002). Content is `"+"` or `"-"`.
|
||||
pub fn build_vote(
|
||||
channel_id: Uuid,
|
||||
@@ -447,8 +427,6 @@ pub fn build_vote(
|
||||
Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 9: build_reaction ────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars.
|
||||
pub fn build_reaction(
|
||||
target_event_id: nostr::EventId,
|
||||
@@ -481,16 +459,12 @@ pub fn build_custom_emoji_reaction(
|
||||
Ok(EventBuilder::new(Kind::Custom(7), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 10: build_remove_reaction ────────────────────────────────────────
|
||||
|
||||
/// Build a deletion event targeting a reaction (kind 5).
|
||||
pub fn build_remove_reaction(reaction_event_id: nostr::EventId) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["e", &reaction_event_id.to_hex()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder: per-user custom emoji set ───────────────────────────────────────
|
||||
|
||||
/// d-tag for a member's own custom emoji set. Each member publishes one
|
||||
/// user-signed kind:30030 under this d-tag; the workspace palette is the
|
||||
/// client-side union of every member's set.
|
||||
@@ -519,16 +493,12 @@ pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result<EventBuilder, Sd
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 11: build_set_canvas ─────────────────────────────────────────────
|
||||
|
||||
/// Build a canvas update event (kind 40100).
|
||||
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 12: build_profile ────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-01 profile metadata event (kind 0).
|
||||
///
|
||||
/// Only present (Some) fields are included in the JSON object.
|
||||
@@ -559,8 +529,6 @@ pub fn build_profile(
|
||||
Ok(EventBuilder::new(Kind::Custom(0), content).tags([]))
|
||||
}
|
||||
|
||||
// ── Builder 13: build_add_member ─────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 add-member event (kind 9000).
|
||||
pub fn build_add_member(
|
||||
channel_id: Uuid,
|
||||
@@ -578,8 +546,6 @@ pub fn build_add_member(
|
||||
Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 14: build_remove_member ──────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 remove-member event (kind 9001).
|
||||
pub fn build_remove_member(
|
||||
channel_id: Uuid,
|
||||
@@ -593,16 +559,12 @@ pub fn build_remove_member(
|
||||
Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 15: build_leave ──────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 leave-request event (kind 9022).
|
||||
pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 16: build_update_channel ─────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl (kind 9002).
|
||||
///
|
||||
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
|
||||
@@ -645,8 +607,6 @@ pub fn build_update_channel(
|
||||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 17: build_set_topic ──────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 edit-metadata event for topic (kind 9002).
|
||||
pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![
|
||||
@@ -656,8 +616,6 @@ pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result<EventBuilder, Sd
|
||||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 18: build_set_purpose ────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 edit-metadata event for purpose (kind 9002).
|
||||
pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![
|
||||
@@ -667,8 +625,6 @@ pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder
|
||||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 19: build_create_channel ─────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 create-group event (kind 9007).
|
||||
///
|
||||
/// `ttl`: `Some(secs)` makes the channel ephemeral with that lifetime in
|
||||
@@ -698,16 +654,12 @@ pub fn build_create_channel(
|
||||
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 20: build_join ───────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 join-request event (kind 9021).
|
||||
pub fn build_join(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(9021), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 21: build_archive ────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 archive event (kind 9002, `["archived", "true"]`).
|
||||
pub fn build_archive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![
|
||||
@@ -717,8 +669,6 @@ pub fn build_archive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 22: build_unarchive ──────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 unarchive event (kind 9002, `["archived", "false"]`).
|
||||
pub fn build_unarchive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![
|
||||
@@ -728,16 +678,12 @@ pub fn build_unarchive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 23: build_delete_channel ─────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-29 delete-group event (kind 9008).
|
||||
pub fn build_delete_channel(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(9008), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 24: build_note ───────────────────────────────────────────────────
|
||||
|
||||
/// Build a global text note (kind:1, NIP-01).
|
||||
///
|
||||
/// `reply_to_event_id`: adds a single `["e", <id>, "", "reply"]` tag.
|
||||
@@ -756,8 +702,6 @@ pub fn build_note(
|
||||
Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 25: build_contact_list ───────────────────────────────────────────
|
||||
|
||||
/// Maximum number of contacts allowed in a single contact list event.
|
||||
const MAX_CONTACTS: usize = 10_000;
|
||||
|
||||
@@ -821,8 +765,6 @@ pub fn build_contact_list(
|
||||
Ok(EventBuilder::new(Kind::Custom(3), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Helper: extract_channel_id ───────────────────────────────────────────────
|
||||
|
||||
/// Extract the channel UUID from an event's `h` tag.
|
||||
///
|
||||
/// Returns `None` if no `h` tag is present or the value is not a valid UUID.
|
||||
@@ -837,8 +779,6 @@ pub fn extract_channel_id(event: &nostr::Event) -> Option<Uuid> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Builder 30: build_repo_announcement ──────────────────────────────────────
|
||||
|
||||
/// Build a git repository announcement event (kind:30617, NIP-34).
|
||||
///
|
||||
/// Creates or updates a repository. The `repo_id` is the unique identifier
|
||||
@@ -959,8 +899,6 @@ pub fn build_repo_announcement(
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Git collaboration: patches, issues, status (NIP-34) ─────────────────────
|
||||
|
||||
/// Repository coordinate — owner pubkey + `d`-tag identifier.
|
||||
///
|
||||
/// Renders as the `a`-tag value clients use to address a kind:30617
|
||||
@@ -1290,8 +1228,6 @@ pub fn build_git_status(
|
||||
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 31: build_workflow_def ────────────────────────────────────────────
|
||||
|
||||
/// Build a workflow definition event (kind 30620).
|
||||
///
|
||||
/// - `channel_id`: the channel this workflow belongs to (h-tag)
|
||||
@@ -1310,8 +1246,6 @@ pub fn build_workflow_def(
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 32: build_workflow_update ─────────────────────────────────────────
|
||||
|
||||
/// Build a workflow update event (kind 30620) for an existing workflow.
|
||||
///
|
||||
/// Updates an existing workflow definition in-place via the parameterized
|
||||
@@ -1330,8 +1264,6 @@ pub fn build_workflow_update(
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 33: build_workflow_delete ─────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-09 deletion event targeting a workflow definition (kind 5).
|
||||
///
|
||||
/// The `a`-tag addresses the parameterized replaceable event
|
||||
@@ -1348,16 +1280,12 @@ pub fn build_workflow_delete(
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 34: build_workflow_trigger ────────────────────────────────────────
|
||||
|
||||
/// Build a workflow trigger event (kind 46020).
|
||||
pub fn build_workflow_trigger(workflow_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||||
let tags = vec![tag(&["d", &workflow_id.to_string()])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 35: build_workflow_approval ───────────────────────────────────────
|
||||
|
||||
/// Build a workflow approval event — kind 46030 (grant) or 46031 (deny).
|
||||
///
|
||||
/// - `token_hash`: hex-encoded SHA-256 of the approval token UUID (d-tag).
|
||||
@@ -1383,8 +1311,6 @@ pub fn build_workflow_approval(
|
||||
Ok(EventBuilder::new(Kind::Custom(kind as u16), note).tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 36: build_dm_open ────────────────────────────────────────────────
|
||||
|
||||
/// Build a DM open event (kind 41010).
|
||||
///
|
||||
/// `pubkeys` must be 1–8 hex-encoded pubkeys to include in the DM conversation.
|
||||
@@ -1402,8 +1328,6 @@ pub fn build_dm_open(pubkeys: &[&str]) -> Result<EventBuilder, SdkError> {
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_DM_OPEN as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 37: build_dm_add_member ──────────────────────────────────────────
|
||||
|
||||
/// Build a DM add-member event (kind 41011).
|
||||
pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result<EventBuilder, SdkError> {
|
||||
let pk = check_pubkey_hex(pubkey, "pubkey")?;
|
||||
@@ -1411,8 +1335,6 @@ pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result<EventBuilde
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_DM_ADD_MEMBER as u16), "").tags(tags))
|
||||
}
|
||||
|
||||
// ── Builder 38: build_presence_update ────────────────────────────────────────
|
||||
|
||||
/// Build a presence update event (kind 20001).
|
||||
///
|
||||
/// `status` must be one of: `"online"`, `"away"`, `"offline"`.
|
||||
@@ -1431,8 +1353,6 @@ pub fn build_presence_update(status: &str) -> Result<EventBuilder, SdkError> {
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags))
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1481,8 +1401,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
// ── build_message ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn message_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1633,8 +1551,6 @@ mod tests {
|
||||
assert!(build_message(cid, &max, None, &[], false, &[]).is_ok());
|
||||
}
|
||||
|
||||
// ── build_forum_post ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn forum_post_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1653,8 +1569,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── build_forum_comment ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn forum_comment_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1668,8 +1582,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||||
}
|
||||
|
||||
// ── build_diff_message ───────────────────────────────────────────────────
|
||||
|
||||
fn good_diff_meta() -> DiffMeta {
|
||||
DiffMeta {
|
||||
repo_url: "https://github.com/example/repo".into(),
|
||||
@@ -1783,8 +1695,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "alt", "patch for bug fix"));
|
||||
}
|
||||
|
||||
// ── build_edit ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn edit_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1805,8 +1715,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── build_delete_message ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn delete_message_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1818,8 +1726,6 @@ mod tests {
|
||||
assert_eq!(ev.content, "");
|
||||
}
|
||||
|
||||
// ── build_delete_compat ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn delete_compat_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1831,8 +1737,6 @@ mod tests {
|
||||
assert_eq!(ev.content, "");
|
||||
}
|
||||
|
||||
// ── build_vote ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn vote_up() {
|
||||
let cid = uuid();
|
||||
@@ -1850,8 +1754,6 @@ mod tests {
|
||||
assert_eq!(ev.content, "-");
|
||||
}
|
||||
|
||||
// ── build_reaction ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reaction_happy_path() {
|
||||
let eid = event_id();
|
||||
@@ -1903,8 +1805,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "emoji", "party"));
|
||||
}
|
||||
|
||||
// ── build_remove_reaction ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn remove_reaction_happy_path() {
|
||||
let eid = event_id();
|
||||
@@ -1913,8 +1813,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||||
}
|
||||
|
||||
// ── build_set_canvas ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_canvas_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1924,8 +1822,6 @@ mod tests {
|
||||
assert_eq!(ev.content, "# Canvas\nHello");
|
||||
}
|
||||
|
||||
// ── build_profile ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn profile_all_fields() {
|
||||
let ev = sign(
|
||||
@@ -1964,8 +1860,6 @@ mod tests {
|
||||
assert!(v.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── build_add_member ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn add_member_with_role() {
|
||||
let cid = uuid();
|
||||
@@ -1985,8 +1879,6 @@ mod tests {
|
||||
assert!(tag_values(&ev, "role").is_empty());
|
||||
}
|
||||
|
||||
// ── build_remove_member ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn remove_member_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -1996,8 +1888,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "p", pubkey));
|
||||
}
|
||||
|
||||
// ── build_leave ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn leave_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2006,8 +1896,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||||
}
|
||||
|
||||
// ── build_update_channel ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn update_channel_name_and_about() {
|
||||
let cid = uuid();
|
||||
@@ -2054,8 +1942,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── build_set_topic ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_topic_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2064,8 +1950,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "topic", "Rust async patterns"));
|
||||
}
|
||||
|
||||
// ── build_set_purpose ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_purpose_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2074,8 +1958,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "purpose", "Team coordination"));
|
||||
}
|
||||
|
||||
// ── build_create_channel ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn create_channel_all_fields() {
|
||||
let cid = uuid();
|
||||
@@ -2133,8 +2015,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "ttl", "3600"));
|
||||
}
|
||||
|
||||
// ── build_join ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn join_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2143,8 +2023,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||||
}
|
||||
|
||||
// ── build_archive / build_unarchive ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn archive_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2161,8 +2039,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "archived", "false"));
|
||||
}
|
||||
|
||||
// ── build_delete_channel ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn delete_channel_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2171,8 +2047,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||||
}
|
||||
|
||||
// ── extract_channel_id ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_channel_id_present() {
|
||||
let cid = uuid();
|
||||
@@ -2198,8 +2072,6 @@ mod tests {
|
||||
assert_eq!(extract_channel_id(&ev), None);
|
||||
}
|
||||
|
||||
// ── Builder 24: build_note ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_note_happy_path() {
|
||||
let builder = build_note("hello world", None).unwrap();
|
||||
@@ -2247,8 +2119,6 @@ mod tests {
|
||||
assert!(event.tags.is_empty());
|
||||
}
|
||||
|
||||
// ── Builder 25: build_contact_list ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_contact_list_happy_path() {
|
||||
let pubkey = "a".repeat(64);
|
||||
@@ -2375,8 +2245,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── build_repo_announcement ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn repo_announcement_happy_path_all_fields() {
|
||||
let ev = sign(
|
||||
@@ -2490,8 +2358,6 @@ mod tests {
|
||||
assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git");
|
||||
}
|
||||
|
||||
// ── build_git_patch / build_git_issue / build_git_status (NIP-34) ───────
|
||||
|
||||
#[test]
|
||||
fn git_patch_happy_path_minimal() {
|
||||
let owner = "a".repeat(64);
|
||||
@@ -2797,8 +2663,6 @@ mod tests {
|
||||
assert_eq!(parts.get(3).map(|v| v.as_str()), Some(pubkey.as_str()));
|
||||
}
|
||||
|
||||
// ── Builder 31: build_workflow_def ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workflow_def_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2817,8 +2681,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||||
}
|
||||
|
||||
// ── Builder 32: build_workflow_update ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workflow_update_includes_h_tag() {
|
||||
let cid = uuid();
|
||||
@@ -2836,8 +2698,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||||
}
|
||||
|
||||
// ── Builder 33: build_workflow_delete ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workflow_delete_happy_path() {
|
||||
let pk = "a".repeat(64);
|
||||
@@ -2856,8 +2716,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── Builder 34: build_workflow_trigger ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workflow_trigger_happy_path() {
|
||||
let wid = uuid();
|
||||
@@ -2866,8 +2724,6 @@ mod tests {
|
||||
assert!(has_tag(&ev, "d", &wid.to_string()));
|
||||
}
|
||||
|
||||
// ── Builder 35: build_workflow_approval ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workflow_approval_grant() {
|
||||
let hash = "a".repeat(64);
|
||||
@@ -2897,8 +2753,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── Builder 36: build_dm_open ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dm_open_happy_path() {
|
||||
let pk = "a".repeat(64);
|
||||
@@ -2927,8 +2781,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── Builder 37: build_dm_add_member ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dm_add_member_happy_path() {
|
||||
let cid = uuid();
|
||||
@@ -2945,8 +2797,6 @@ mod tests {
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── Builder 38: build_presence_update ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn presence_update_content_is_status() {
|
||||
let ev = sign(build_presence_update("online").unwrap());
|
||||
|
||||
@@ -21,8 +21,6 @@ pub use builders::*;
|
||||
/// Re-export kind constants so consumers don't need buzz-core directly.
|
||||
pub use buzz_core::kind;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thread reference for reply builders (NIP-10 markers).
|
||||
///
|
||||
/// - Direct reply (root == parent): emits `["e", root, "", "reply"]`
|
||||
@@ -76,8 +74,6 @@ pub struct CustomEmoji {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
// ── Channel / Member enums (re-exported from buzz-core) ────────────────────
|
||||
|
||||
/// Channel type.
|
||||
pub use buzz_core::channel::ChannelType as ChannelKind;
|
||||
/// Channel visibility.
|
||||
@@ -85,8 +81,6 @@ pub use buzz_core::channel::ChannelVisibility as Visibility;
|
||||
/// Member role.
|
||||
pub use buzz_core::channel::MemberRole;
|
||||
|
||||
// ── Error ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Errors returned by SDK builder functions.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SdkError {
|
||||
|
||||
@@ -390,8 +390,6 @@ pub fn extract_nostr_uris(content: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── extract_at_names ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_matches_basic() {
|
||||
assert_eq!(extract_at_names("hello @alice"), vec!["alice"]);
|
||||
@@ -428,8 +426,6 @@ mod tests {
|
||||
assert!(extract_at_names("hello @").is_empty());
|
||||
}
|
||||
|
||||
// ── extract_at_mentions_with_known ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn known_multiword_name_matches_fully() {
|
||||
// "Will Pfleger" should match @Will Pfleger, not just @Will.
|
||||
@@ -546,8 +542,6 @@ mod tests {
|
||||
assert_eq!(result, vec!["alice"]);
|
||||
}
|
||||
|
||||
// ── match_names_to_profiles ─────────────────────────────────────────
|
||||
|
||||
fn profile<'a>(pk: &'a str, json: &'a str) -> MentionProfile<'a> {
|
||||
MentionProfile {
|
||||
pubkey: pk,
|
||||
@@ -619,8 +613,6 @@ mod tests {
|
||||
assert!(match_names_to_profiles(&[], &profiles).is_empty());
|
||||
}
|
||||
|
||||
// ── merge_mentions ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_appends_new_and_skips_dupes() {
|
||||
let mut m = vec!["a".to_string()];
|
||||
@@ -644,8 +636,6 @@ mod tests {
|
||||
assert!(!m.contains(&"extra".to_string()));
|
||||
}
|
||||
|
||||
// ── normalize_mention_pubkeys ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_dedups() {
|
||||
let pks = vec!["ABC".to_string(), "abc".to_string(), "DEF".to_string()];
|
||||
@@ -669,8 +659,6 @@ mod tests {
|
||||
assert!(normalize_mention_pubkeys(&[], Some("anything")).is_empty());
|
||||
}
|
||||
|
||||
// ── strip_code_regions ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_removes_fenced_block() {
|
||||
let input = "before\n```rust\nlet x = 1;\n```\nafter";
|
||||
@@ -711,8 +699,6 @@ mod tests {
|
||||
assert!(stripped.contains("world"));
|
||||
}
|
||||
|
||||
// ── extract_nostr_uris ──────────────────────────────────────────────
|
||||
|
||||
const TEST_NPUB1: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
|
||||
const TEST_HEX1: &str = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
|
||||
const TEST_NPUB2: &str = "npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw";
|
||||
|
||||
@@ -28,8 +28,6 @@ use serde_json::Value;
|
||||
|
||||
use crate::SdkError;
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate the `conditions` string per the NIP-OA spec.
|
||||
///
|
||||
/// Empty string is valid. Non-empty must be `clause` or `clause&clause&...`
|
||||
@@ -134,8 +132,6 @@ fn parse_json_array(s: &str) -> Result<Vec<Value>, SdkError> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`.
|
||||
///
|
||||
/// Signs the preimage with `owner_keys` using BIP-340 Schnorr.
|
||||
@@ -302,8 +298,6 @@ pub fn parse_auth_tag(json_str: &str) -> Result<Tag, SdkError> {
|
||||
.map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}")))
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -499,8 +493,6 @@ mod tests {
|
||||
assert_eq!(format!("{digest:x}"), expected);
|
||||
}
|
||||
|
||||
// ── Conditions validation ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_valid_conditions() {
|
||||
// These should all pass through validate_conditions
|
||||
|
||||
@@ -329,8 +329,6 @@ mod tests {
|
||||
assert_eq!(doc["channel_id"].as_str().unwrap(), "__global__");
|
||||
}
|
||||
|
||||
// ── kind:0 flattening for searchability ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn kind0_appends_display_name_for_tokenization() {
|
||||
let stored = make_stored_event(
|
||||
|
||||
@@ -25,8 +25,6 @@ use serde_json::Value;
|
||||
|
||||
const KIND_EVENT_REMINDER: u16 = 30300;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
||||
}
|
||||
@@ -160,8 +158,6 @@ async fn count_events_http(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write-path validation tests ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_accepted_with_valid_not_before() {
|
||||
@@ -373,8 +369,6 @@ async fn test_reminder_accepted_with_malformed_expiration() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── d-tag validation tests ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_rejected_missing_d_tag() {
|
||||
@@ -460,8 +454,6 @@ async fn test_reminder_accepted_expiration_without_not_before() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Read-path filtering tests (HTTP bridge) ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_author_can_query_own_reminders_http() {
|
||||
@@ -592,8 +584,6 @@ async fn test_other_user_cannot_count_reminders_http() {
|
||||
assert_eq!(status, 403);
|
||||
}
|
||||
|
||||
// ── Read-path filtering tests (WebSocket) ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_author_can_subscribe_to_own_reminders_ws() {
|
||||
@@ -887,8 +877,6 @@ async fn test_reminder_replacement_semantics() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fan-out isolation, WS search isolation, WS COUNT tests ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_fanout_isolation_other_user_does_not_receive_reminder() {
|
||||
@@ -1095,8 +1083,6 @@ async fn test_reminder_rejected_not_before_too_far_in_future() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scheduler delivery test ──────────────────────────────────────────────────
|
||||
|
||||
/// True if the event carries a `d` tag equal to `d_tag`.
|
||||
fn has_d_tag(event: &nostr::Event, d_tag: &str) -> bool {
|
||||
event.tags.iter().any(|t| {
|
||||
|
||||
@@ -448,8 +448,6 @@ async fn git_concurrent_push_one_wins_and_repo_recovers() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── tiny tempdir (avoid an extra dep) ─────────────────────────────────────────
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn path(&self) -> &Path {
|
||||
|
||||
@@ -52,8 +52,6 @@ fn build_long_form_event(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// kind:30023 events are accepted by the relay.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
|
||||
@@ -91,8 +91,6 @@ fn agent_d_tag() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string().repeat(2)
|
||||
}
|
||||
|
||||
// ── Publish and query back ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_publish_and_query() {
|
||||
@@ -136,8 +134,6 @@ async fn test_managed_agent_publish_and_query() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Round-trip fidelity (relay returns only what was published) ──────────────
|
||||
|
||||
/// The relay round-trips published content byte-for-byte: a projection-shaped
|
||||
/// body goes out, and the relay returns exactly those fields and nothing more.
|
||||
/// The body here is a hand-built secret-free literal (`agent_projection_content`),
|
||||
@@ -225,8 +221,6 @@ async fn test_managed_agent_round_trips_only_projected_fields() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── NIP-33 replacement semantics ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_nip33_replacement_newer_wins() {
|
||||
@@ -272,8 +266,6 @@ async fn test_managed_agent_nip33_replacement_newer_wins() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── NIP-09 coordinate deletion (tombstone) ───────────────────────────────────
|
||||
|
||||
/// The a-tag tombstone is the only state-destroying op in the managed-agent
|
||||
/// flow. Publish an agent, confirm it is live, publish the a-tag-only tombstone
|
||||
/// at its coordinate, then assert the query returns it gone.
|
||||
|
||||
@@ -22,8 +22,6 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
@@ -35,8 +33,6 @@ fn http_client() -> Client {
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── Blossom auth helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
@@ -60,8 +56,6 @@ fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Minimal 1×1 JPEG ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
@@ -92,8 +86,6 @@ fn tiny_jpeg() -> Vec<u8> {
|
||||
]
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
|
||||
/// Also checks BlobDescriptor fields and thumbnail endpoint.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -59,8 +59,6 @@ async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
// ── Minimal test images ─────────────────────────────────────────────────────
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
@@ -125,8 +123,6 @@ fn tiny_webp() -> Vec<u8> {
|
||||
]
|
||||
}
|
||||
|
||||
// ── Auth edge case helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec<Tag>) -> nostr::Event {
|
||||
EventBuilder::new(Kind::from(kind), content)
|
||||
.tags(tags)
|
||||
@@ -150,10 +146,6 @@ async fn upload_with_auth(
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MULTI-FORMAT UPLOAD TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_png_roundtrip() {
|
||||
@@ -218,10 +210,6 @@ async fn test_upload_webp_roundtrip() {
|
||||
println!("✅ WebP upload: {}", desc["url"]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// AUTH EDGE CASE TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_wrong_kind() {
|
||||
@@ -382,10 +370,6 @@ async fn test_auth_server_tag_correct() {
|
||||
println!("✅ Correct server tag → 200");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CONTENT VALIDATION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_svg_accepted_as_text_xml() {
|
||||
@@ -462,10 +446,6 @@ async fn test_upload_random_bytes_accepted() {
|
||||
println!("✅ Random bytes → 200 as octet-stream");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CONCURRENT UPLOAD TEST
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_concurrent_upload_same_file() {
|
||||
@@ -493,10 +473,6 @@ async fn test_concurrent_upload_same_file() {
|
||||
println!("✅ Concurrent upload: both succeeded, same sha256/url");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WEBSOCKET IMETA VALIDATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_valid_imeta() {
|
||||
|
||||
@@ -15,8 +15,6 @@ use reqwest::{Client, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
@@ -28,8 +26,6 @@ fn http_client() -> Client {
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── Blossom auth helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
@@ -51,8 +47,6 @@ fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Minimal MP4 builder ───────────────────────────────────────────────────────
|
||||
|
||||
/// Build a minimal but structurally valid fast-start MP4 (H.264, 1s, 320×240).
|
||||
///
|
||||
/// Layout: ftyp | moov(mvhd + trak(tkhd + mdia(mdhd + hdlr + minf(vmhd + dinf + stbl)))) | mdat
|
||||
@@ -241,8 +235,6 @@ fn build_test_mp4() -> Vec<u8> {
|
||||
[ftyp, moov, mdat].concat()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes
|
||||
/// video-specific fields (duration, dim) and the blob is retrievable.
|
||||
#[tokio::test]
|
||||
@@ -434,8 +426,6 @@ async fn test_video_upload_no_auth_returns_401() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Poster frame + imeta integration tests ───────────────────────────────────
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
relay_http_url()
|
||||
.replace("http://", "ws://")
|
||||
|
||||
@@ -85,8 +85,6 @@ fn mesh_status_filter() -> Filter {
|
||||
.identifier(MESH_STATUS_D_TAG)
|
||||
}
|
||||
|
||||
// ── (1) member reads the relay-signed status, with dial pointer, no secrets ──
|
||||
|
||||
/// Assertion 1: an authenticated relay member can REQ the relay-signed
|
||||
/// kind:30621 status event; its content carries the sanitized projection
|
||||
/// (mesh/models/serveTargets with EndpointAddr dial pointers) and NO secrets
|
||||
@@ -160,8 +158,6 @@ async fn trust_member_reads_mesh_status() {
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ── (2) non-member read denied ───────────────────────────────────────────────
|
||||
|
||||
/// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing
|
||||
/// back for a kind:30621 REQ — membership gates the read.
|
||||
///
|
||||
@@ -202,8 +198,6 @@ async fn trust_nonmember_read_denied() {
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ── (4) the demo: B's agent completes a chat against A's model over the mesh ──
|
||||
|
||||
/// Assertion 4 (the headline demo): with desktop A serving a model and desktop
|
||||
/// B running a mesh client + a launched buzz-agent pointed at B's local
|
||||
/// `:9337/v1`, a chat completion returns a non-empty response routed over the
|
||||
@@ -267,8 +261,6 @@ async fn live_agent_completes_chat_over_mesh() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── (6) split variant ────────────────────────────────────────────────────────
|
||||
|
||||
/// Assertion 6 (split): a model too large for one node + two serve nodes in the
|
||||
/// same mesh → mesh auto-splits → the same chat (assertion 4) completes via the
|
||||
/// split route. Auto-split is mesh runtime behavior (no Buzz code); this row
|
||||
|
||||
@@ -24,8 +24,6 @@ use std::time::Duration;
|
||||
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
@@ -256,8 +254,6 @@ async fn query_channel_messages(keys: &Keys, channel_id: &str) -> Vec<serde_json
|
||||
body.as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Phase 1: NIP-50 Search ────────────────────────────────────────────────────
|
||||
|
||||
/// Send a message with unique content, then search for it.
|
||||
/// Verify: events returned before EOSE, content matches, EOSE received.
|
||||
/// Verify: no live events delivered after EOSE (search is one-shot).
|
||||
@@ -436,8 +432,6 @@ async fn test_nip50_search_empty_results() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Phase 2: NIP-10 Threads ───────────────────────────────────────────────────
|
||||
|
||||
/// Send a root message via REST, then send a WS reply with NIP-10 e-tags.
|
||||
/// Verify: relay accepts the reply. Query thread via REST and verify reply appears.
|
||||
#[tokio::test]
|
||||
@@ -588,8 +582,6 @@ async fn test_nip10_root_mismatch_rejected() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Phase 3: NIP-17 Gift Wraps ────────────────────────────────────────────────
|
||||
|
||||
/// Create a kind:1059 event signed by an ephemeral key (different from auth key).
|
||||
/// Verify: relay accepts despite pubkey mismatch (gift wraps are exempt).
|
||||
#[tokio::test]
|
||||
@@ -760,8 +752,6 @@ async fn test_nip17_gift_wrap_recipient_receives() {
|
||||
client_b.disconnect().await.expect("disconnect B");
|
||||
}
|
||||
|
||||
// ── Phase 4: DM Discovery ─────────────────────────────────────────────────────
|
||||
|
||||
/// Create a DM via REST, then subscribe as a participant to verify discovery events.
|
||||
/// Verify: kind:39000 event received with `hidden` and `private` tags.
|
||||
/// Verify: kind:44100 membership notification received.
|
||||
@@ -788,7 +778,6 @@ async fn test_dm_discovery_events_emitted() {
|
||||
.await
|
||||
.expect("client A connect");
|
||||
|
||||
// ── kind:44100 membership notification addressed to A ──
|
||||
let sid_membership = sub_id("dm-discovery-44100");
|
||||
let membership_filter = Filter::new().kind(Kind::Custom(44100)).custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::P),
|
||||
@@ -824,7 +813,6 @@ async fn test_dm_discovery_events_emitted() {
|
||||
membership.tags
|
||||
);
|
||||
|
||||
// ── kind:39000 discovery event for this DM channel ──
|
||||
let sid_discovery = sub_id("dm-discovery-39000");
|
||||
let discovery_filter = Filter::new()
|
||||
.kind(Kind::Custom(39000))
|
||||
@@ -872,8 +860,6 @@ async fn test_dm_discovery_events_emitted() {
|
||||
client_a.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Phase 5: Regression Tests ─────────────────────────────────────────────────
|
||||
|
||||
/// Send a non-broadcast NIP-10 reply AND a broadcast (`["broadcast","1"]`)
|
||||
/// reply, then prove the relay's real top-level rule both directions.
|
||||
///
|
||||
@@ -1221,8 +1207,6 @@ async fn test_empty_kinds_returns_zero_events() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Phase 6: NIP-DV DM Visibility ─────────────────────────────────────────────
|
||||
|
||||
/// Helper: read the viewer's latest relay-signed NIP-DV snapshot event
|
||||
/// (kind:30622, queried by `#p` since snapshots are `#p`-gated to their owner).
|
||||
/// Returns `None` if no snapshot exists yet.
|
||||
|
||||
@@ -45,8 +45,6 @@ fn persona_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) ->
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Publish and query back ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_persona_publish_and_query() {
|
||||
@@ -97,8 +95,6 @@ async fn test_persona_publish_and_query() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── NIP-33 replacement semantics ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_persona_nip33_replacement_newer_wins() {
|
||||
@@ -192,8 +188,6 @@ async fn test_persona_nip33_older_does_not_replace_newer() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── D-tag validation ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_persona_rejects_empty_d_tag() {
|
||||
@@ -374,8 +368,6 @@ async fn test_persona_accepts_valid_slugs() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── Multiple personas per author ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_persona_multiple_per_author() {
|
||||
|
||||
@@ -1916,8 +1916,6 @@ async fn test_membership_notification_mixed_filter_rejected() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ─── Private channel membership permission tests ───────────────────────────────
|
||||
|
||||
/// Create a private channel over WebSocket and return the channel UUID.
|
||||
async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> String {
|
||||
let channel_uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
@@ -31,8 +31,6 @@ use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use reqwest::Client;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// WebSocket relay URL (e.g. `ws://localhost:3001`).
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
||||
@@ -180,8 +178,6 @@ async fn set_profile_via_event(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Channel tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/channels returns a non-empty list with the expected fields.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -341,8 +337,6 @@ async fn test_channels_requires_auth() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/search returns results scoped to the authenticated user's accessible channels.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -454,8 +448,6 @@ async fn test_search_empty_query_returns_all() {
|
||||
assert!(body["found"].is_number(), "'found' must be a number");
|
||||
}
|
||||
|
||||
// ── Presence tests ────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/presence returns "offline" for a pubkey with no presence event.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -752,8 +744,6 @@ async fn test_set_presence_missing_field() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agents tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/agents returns a JSON array with the expected fields.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -852,8 +842,6 @@ async fn test_agents_scoped_to_accessible_channels() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feed tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/feed returns a structured feed with the expected shape.
|
||||
///
|
||||
/// This test is skipped if the relay does not expose `/api/feed` (older builds).
|
||||
@@ -1012,8 +1000,6 @@ async fn test_feed_requires_auth() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth edge cases ───────────────────────────────────────────────────────────
|
||||
|
||||
/// An invalid X-Pubkey header is rejected with 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -1049,8 +1035,6 @@ async fn test_valid_pubkey_header_accepted() {
|
||||
assert_eq!(resp.status(), 200, "expected 200 for valid X-Pubkey header");
|
||||
}
|
||||
|
||||
// ── Public profile tests ──────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/users/:pubkey/profile returns the profile for a known user.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -1309,8 +1293,6 @@ async fn test_batch_profiles_case_normalized() {
|
||||
assert_eq!(profiles.len(), 1, "uppercase pubkey should match");
|
||||
}
|
||||
|
||||
// ── NIP-05 tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /.well-known/nostr.json?name=nonexistent returns empty names and relays.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -1446,8 +1428,6 @@ async fn test_nip05_clear_handle() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent Channel Protection tests ───────────────────────────────────────────
|
||||
|
||||
/// PUT /api/users/me/channel-add-policy updates the policy and returns the new value.
|
||||
/// Cycles through owner_only → nobody → anyone to verify each round-trip.
|
||||
#[tokio::test]
|
||||
@@ -1525,10 +1505,6 @@ async fn test_set_channel_add_policy_rejects_invalid() {
|
||||
assert_eq!(resp.status(), 400, "invalid policy value should return 400");
|
||||
}
|
||||
|
||||
// ── Thread reply mention p-tag tests ──────────────────────────────────────────
|
||||
|
||||
// ── Notes (kind:1) tests ──────────────────────────────────────────────────────
|
||||
|
||||
/// Phase 1: GET /api/events/{id} must return 200 for kind:1 text note.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
@@ -1697,7 +1673,6 @@ async fn test_get_contact_list_returns_latest() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
// ── First contact list: 2 contacts ───────────────────────────────────────
|
||||
let contact1 = Keys::generate().public_key().to_hex();
|
||||
let contact2 = Keys::generate().public_key().to_hex();
|
||||
let tags_v1 = vec![
|
||||
@@ -1726,7 +1701,6 @@ async fn test_get_contact_list_returns_latest() {
|
||||
// Wait 1 second so the replacement event gets a strictly greater created_at.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
// ── Second contact list: 1 different contact ──────────────────────────────
|
||||
let contact3 = Keys::generate().public_key().to_hex();
|
||||
let tags_v2 = vec![Tag::parse(["p", &contact3]).unwrap()];
|
||||
let event_v2 = EventBuilder::new(Kind::Custom(3), "")
|
||||
@@ -1748,7 +1722,6 @@ async fn test_get_contact_list_returns_latest() {
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ── Fetch and assert replacement ──────────────────────────────────────────
|
||||
let url = format!("{}/api/users/{}/contact-list", relay_http_url(), pubkey_hex);
|
||||
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
||||
assert_eq!(
|
||||
|
||||
@@ -61,8 +61,6 @@ fn team_delete_event(keys: &Keys, d_tag: &str) -> nostr::Event {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Publish and query back ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_publish_and_query() {
|
||||
@@ -108,8 +106,6 @@ async fn test_team_publish_and_query() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── NIP-33 replacement semantics ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_nip33_replacement_newer_wins() {
|
||||
@@ -155,8 +151,6 @@ async fn test_team_nip33_replacement_newer_wins() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
// ── NIP-09 coordinate deletion (tombstone) ───────────────────────────────────
|
||||
|
||||
/// The a-tag tombstone is the only state-destroying op in the team flow: it
|
||||
/// removes the team for every client and across reboots. This proves the relay
|
||||
/// acts on it — publish a team, confirm it is live, publish the a-tag-only
|
||||
|
||||
@@ -25,8 +25,6 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// WebSocket relay URL (e.g. `ws://localhost:3000`).
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
||||
@@ -47,8 +45,6 @@ fn http_client() -> Client {
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── NIP-98 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `Authorization: Nostr <base64>` header value for NIP-98 HTTP Auth.
|
||||
///
|
||||
/// Uses kind 27235 (`Kind::HttpAuth`) with `u`, `method`, and `payload` tags
|
||||
@@ -92,8 +88,6 @@ fn build_nip98_header_no_payload(keys: &Keys, url: &str, method: &str) -> String
|
||||
format!("Nostr {encoded}")
|
||||
}
|
||||
|
||||
// ── Mint helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Mint a token via dev-mode `X-Pubkey` header. Returns the parsed response body.
|
||||
async fn mint_token_dev(
|
||||
client: &Client,
|
||||
@@ -123,8 +117,6 @@ async fn mint_token_dev(
|
||||
resp.json().await.expect("response JSON")
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/tokens via dev-mode X-Pubkey header returns 201 with token fields.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
|
||||
@@ -48,8 +48,6 @@ fn build_user_status_event(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// kind:30315 events are accepted by the relay.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user