Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions crates/buzz-core/src/desktop_capabilities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Bounded, owner-private runtime facts, not signing access or agent readiness.
use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_CAPABILITIES};
use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag};
use serde::{Deserialize, Serialize};

/// Allowlisted projection of a built-in runtime; never catalog paths or auth data.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RuntimeFact {
/// Built-in catalog identifier.
pub id: String,
/// Discovery's installation/adapter availability, not authentication.
pub availability: String,
/// Whether a separate vendor CLI is required.
pub requires_external_cli: bool,
/// Spawn policy cap; None means no configured cap, not infinite capacity.
pub max_parallelism: Option<u32>,
}

/// Facts at the signed event time, changed only when the projection changes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DesktopCapabilities {
/// Format version.
pub v: u8,
/// Encrypted canonical community.
pub community: String,
/// Local Desktop coordinate.
pub id: String,
/// Sorted, unique built-in runtime facts.
pub runtimes: Vec<RuntimeFact>,
}

/// Validate the bounded public envelope without decrypting it.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_CAPABILITIES)
}

impl DesktopCapabilities {
/// Project onto the persisted Desktop coordinate, not a caller-selected host.
pub fn new(profile: DesktopProfile, mut runtimes: Vec<RuntimeFact>) -> Self {
runtimes.sort_by(|a, b| a.id.cmp(&b.id));
Self {
v: 1,
community: profile.community,
id: profile.id,
runtimes,
}
}

fn validate(&self) -> Result<(), String> {
DesktopProfile::new(self.community.clone(), self.id.clone())?;
if self.v != 1
|| self.runtimes.len() > 8
|| self.runtimes.windows(2).any(|r| r[0].id >= r[1].id)
|| self.runtimes.iter().any(|r| {
r.id.is_empty()
|| r.id.len() > 32
|| !r.id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
|| !matches!(
r.availability.as_str(),
"available"
| "adapter_missing"
| "adapter_outdated"
| "cli_missing"
| "not_installed"
)
|| r.max_parallelism == Some(0)
})
{
return Err("invalid Desktop runtime facts".into());
}
Ok(())
}

/// Encrypt/sign once, then persist these exact bytes for retries.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
self.sign_at(keys, nostr::Timestamp::now())
}

/// Sign at an observed wall-clock second, never a synthesized logical time.
pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result<Event, String> {
self.validate()?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
serde_json::to_string(self).map_err(|e| e.to_string())?,
nip44::Version::V2,
)
.map_err(|e| e.to_string())?;
let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content)
.tag(Tag::identifier(&self.id))
.custom_created_at(observed)
.sign_with_keys(keys)
.map_err(|e| e.to_string())?;
validate_envelope(&event)?;
Ok(event)
}

/// Bounded history/live merge: newest signed time, lower event ID on ties.
pub fn read_latest(
mut events: Vec<Event>,
keys: &Keys,
community: &str,
) -> Result<Vec<(Self, u64)>, String> {
if events.len() > 100 {
return Err("too many Desktop reports".into());
}
events.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
let mut seen = std::collections::HashSet::new();
let mut rows = Vec::new();
for event in events {
let report = Self::read(&event, keys, community)?;
if seen.insert(report.id.clone()) {
rows.push((report, event.created_at.as_secs()));
}
}
Ok(rows)
}

/// Authenticate, decrypt and scope-check before exposing any fact.
pub fn read(event: &Event, keys: &Keys, community: &str) -> Result<Self, String> {
validate_envelope(event)?;
event
.verify()
.map_err(|_| "invalid Desktop report signature")?;
if event.pubkey != keys.public_key() {
return Err("foreign Desktop report".into());
}
let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(|_| "Desktop report decryption failed")?;
let report: Self =
serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop report")?;
report.validate()?;
if report.community != community || Some(report.id.as_str()) != event.tags.identifier() {
return Err("Desktop report scope mismatch".into());
}
Ok(report)
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_scoped_bounded_facts() {
let keys = Keys::generate();
let mut report = DesktopCapabilities::new(
DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(),
vec![],
);
let event = report.sign(&keys).unwrap();
assert_eq!(
DesktopCapabilities::read(&event, &keys, &report.community).unwrap(),
report
);
assert!(DesktopCapabilities::read(&event, &keys, "wss://two.example").is_err());
assert!(DesktopCapabilities::read(&event, &Keys::generate(), &report.community).is_err());
let mut payload = serde_json::to_value(&report).unwrap();
payload["auth"] = serde_json::json!("must not appear");
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
payload.to_string(),
nip44::Version::V2,
)
.unwrap();
let invalid = EventBuilder::new(event.kind, ciphertext)
.tags(event.tags.clone())
.sign_with_keys(&keys)
.unwrap();
assert!(DesktopCapabilities::read(&invalid, &keys, &report.community).is_err());
let mut tampered = event;
tampered.created_at = nostr::Timestamp::from(1);
assert!(DesktopCapabilities::read(&tampered, &keys, &report.community).is_err());
report.runtimes.push(RuntimeFact {
id: "/private/path".into(),
availability: "available".into(),
requires_external_cli: false,
max_parallelism: None,
});
assert!(report.sign(&keys).is_err());
assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_CAPABILITIES));
report.runtimes[0].id = "goose".into();
let old = report.sign(&keys).unwrap();
report.runtimes[0].availability = "cli_missing".into();
let new = report.sign(&keys).unwrap();
let signed = |event: &Event, time| {
EventBuilder::new(event.kind, &event.content)
.tags(event.tags.clone())
.custom_created_at(nostr::Timestamp::from(time))
.sign_with_keys(&keys)
.unwrap()
};
let a = signed(&old, 20);
let b = signed(&new, 20);
let winner = if a.id < b.id { &a } else { &b };
let expected = DesktopCapabilities::read(winner, &keys, &report.community).unwrap();
for events in [vec![signed(&old, 10), a.clone(), b.clone()], vec![b, a]] {
assert_eq!(
DesktopCapabilities::read_latest(events, &keys, &report.community).unwrap(),
vec![(expected.clone(), 20)]
);
}
assert!(
DesktopCapabilities::read_latest(vec![old; 101], &keys, &report.community).is_err()
);
}
}
4 changes: 4 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub const KIND_DESKTOP_PROFILE: u32 = 30180;

/// Owner-private, per-Desktop last-heard observation; not online or readiness.
pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;
/// Owner-private built-in runtime facts per Desktop, not agent readiness.
pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;

/// Kinds whose stored events are readable only by their author.
///
Expand All @@ -138,6 +140,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
];

/// Kinds that require a result-level read gate beyond the filter-layer
Expand Down Expand Up @@ -669,6 +672,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
pub mod desktop_capabilities;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
Expand Down
19 changes: 17 additions & 2 deletions crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 46);
assert_eq!(migrations.len(), 47);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql").contains(
"kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)"
"kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)"
));

// Public push-gateway authority is intentionally deployment-global and
Expand Down Expand Up @@ -2393,6 +2393,7 @@ mod postgres_tests {
(3_u8, 30_179_i32),
(4_u8, 30_180_i32),
(5_u8, 30_181_i32),
(6_u8, 30_182_i32),
] {
sqlx::query(
"INSERT INTO events \
Expand Down Expand Up @@ -2427,6 +2428,7 @@ mod postgres_tests {
(30_179, true),
(30_180, true),
(30_181, true),
(30_182, true),
(30_350, true)
]
);
Expand All @@ -2451,6 +2453,7 @@ mod postgres_tests {
(30_179, Some(true)),
(30_180, Some(true)),
(30_181, Some(true)),
(30_182, Some(true)),
(30_350, None)
]
);
Expand Down Expand Up @@ -2479,6 +2482,17 @@ mod postgres_tests {
"0046 must change brownfield observation FTS"
);

run_migrations_through(&pool, 46).await.unwrap();
let capability_indexed: bool =
sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30182")
.fetch_one(&pool)
.await
.unwrap();
assert!(
capability_indexed,
"0047 must change brownfield capability FTS"
);

run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
Expand All @@ -2496,6 +2510,7 @@ mod postgres_tests {
(30_179, None),
(30_180, None),
(30_181, None),
(30_182, None),
(30_350, None)
]
);
Expand Down
12 changes: 11 additions & 1 deletion crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
use tower::ServiceExt;
Expand Down Expand Up @@ -76,6 +76,12 @@ async fn desktop_observation_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_OBSERVATION).await;
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn desktop_capabilities_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await;
}

async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
Expand All @@ -99,6 +105,10 @@ async fn assert_private_desktop(kind: u32) {
let id = profile.id.clone();
let event = if kind == KIND_DESKTOP_PROFILE {
profile.sign(&owner).unwrap()
} else if kind == KIND_DESKTOP_CAPABILITIES {
buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![])
.sign(&owner)
.unwrap()
} else {
buzz_core::desktop_observation::DesktopObservation::new(profile)
.sign(&owner)
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,11 @@ mod tests {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await;
}

#[tokio::test]
async fn desktop_capabilities_delivers_to_author_only() {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await;
}

async fn assert_author_only_fanout(kind: u32) {
let state = test_state().await;

Expand Down
Loading
Loading