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
195 changes: 195 additions & 0 deletions crates/buzz-core/src/desktop_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//! Owner-private lifecycle requests. Signed order is intent, not process state.
use crate::{
desktop_stop::{hex, read, sign, StopTarget},
kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT},
};
use nostr::{Event, Keys, Tag};
use serde::{Deserialize, Serialize};

/// Start chooses a destination. Restart is a current-host-only one-shot.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Action {
/// Explicit ensure-running, without a remote reachability gate.
Start,
/// Ordinary Stop then one fresh launch, only on the resolved current host.
Restart,
/// Read actual local process status; never starts or stops anything.
Status,
}

/// Immutable request; retries retain its exact signed bytes.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Request {
/// Existing owner/community/agent/Desktop target shape.
pub target: StopTarget,
/// Requested operation, never shell text or configuration.
pub action: Action,
/// Restart's fresh successful Status request ID. None for other actions.
pub observed: Option<String>,
}

/// No credentials, paths, PIDs or raw runtime errors on the wire.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
/// Ordinary process registration/actual status confirms running locally.
Running,
/// Actual status confirms no managed process at this target.
Stopped,
/// Destination-local broker session issuance is not available.
ProvisioningUnavailable,
/// Runtime/readiness/ownership rejected the request.
Failed,
/// Superseded, interrupted, evicted or uncertain; never success.
Unknown,
}

/// Signed Desktop outcome, not agent-signed termination proof.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ResultMessage {
/// Original immutable payload.
pub request: Request,
/// Original signed event identity.
pub id: String,
/// Local Desktop result.
pub outcome: Outcome,
}

/// Public envelope gate before persistence; content remains owner encrypted.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
let kind = event.kind.as_u16() as u32;
let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect();
let result = kind == KIND_DESKTOP_LIFECYCLE_RESULT;
if !matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT)
|| !(132..=4096).contains(&event.content.len())
|| tags.len() != if result { 2 } else { 1 }
|| tags[0].len() != 2
|| tags[0][0] != "d"
|| !hex(&tags[0][1], 32)
|| (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64)))
{
return Err("invalid Desktop lifecycle envelope");
}
Ok(())
}

impl Request {
/// Validate target, action and correlation without inventing credentials.
pub fn validate(&self, community: &str) -> Result<(), String> {
self.target.validate(community)?;
match (self.action, &self.observed) {
(Action::Restart, Some(id)) if hex(id, 64) => Ok(()),
(Action::Start | Action::Status, None) => Ok(()),
_ => Err("invalid Desktop lifecycle observation".into()),
}
}
/// Prepare once; retries must not create a new event/order.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
self.validate(&self.target.community)?;
sign(
self,
keys,
KIND_DESKTOP_LIFECYCLE,
vec![Tag::identifier(&self.target.desktop)],
)
}
/// Authenticate owner, content, routing and captured community.
pub fn read(event: &Event, keys: &Keys, community: &str) -> Result<Self, String> {
let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE)?;
value.validate(community)?;
if event.tags.identifier() != Some(value.target.desktop.as_str()) {
return Err("Desktop lifecycle routing mismatch".into());
}
Ok(value)
}
}
impl ResultMessage {
/// Sign the actual Desktop result. It is immutable for this request.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
self.request.validate(&self.request.target.community)?;
if !hex(&self.id, 64) {
return Err("invalid lifecycle request ID".into());
}
sign(
self,
keys,
KIND_DESKTOP_LIFECYCLE_RESULT,
vec![
Tag::identifier(&self.request.target.desktop),
Tag::parse(["e", &self.id]).map_err(|e| e.to_string())?,
],
)
}
/// Bind every correlation field to the original authenticated request.
pub fn read(
event: &Event,
keys: &Keys,
request: &Event,
community: &str,
) -> Result<Self, String> {
let original = Request::read(request, keys, community)?;
let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE_RESULT)?;
if value.request != original
|| value.id != request.id.to_hex()
|| event.tags.identifier() != Some(original.target.desktop.as_str())
|| event.tags.iter().nth(1).and_then(|t| t.content()) != Some(value.id.as_str())
{
return Err("Desktop lifecycle result mismatch".into());
}
Ok(value)
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_action_and_result_are_bound_to_one_signed_request() {
let keys = Keys::generate();
let mut request = Request {
target: StopTarget {
v: 1,
community: "wss://one.example".into(),
desktop: "a".repeat(32),
agent: Keys::generate().public_key().to_hex(),
},
action: Action::Start,
observed: None,
};
let event = request.sign(&keys).unwrap();
assert_eq!(
Request::read(&event, &keys, &request.target.community).unwrap(),
request
);
assert!(Request::read(&event, &Keys::generate(), &request.target.community).is_err());
assert!(Request::read(&event, &keys, "wss://other.example").is_err());
assert!(
crate::desktop_stop::StopTarget::read(&event, &keys, &request.target.community)
.is_err()
);
let result = ResultMessage {
request: request.clone(),
id: event.id.to_hex(),
outcome: Outcome::ProvisioningUnavailable,
}
.sign(&keys)
.unwrap();
assert_eq!(
ResultMessage::read(&result, &keys, &event, &request.target.community)
.unwrap()
.outcome,
Outcome::ProvisioningUnavailable
);
let other = request.sign(&keys).unwrap();
assert!(ResultMessage::read(&result, &keys, &other, &request.target.community).is_err());
request.action = Action::Restart;
assert!(request.sign(&keys).is_err());
request.observed = Some(event.id.to_hex());
assert!(request.sign(&keys).is_ok());
request.action = Action::Start;
assert!(request.sign(&keys).is_err());
}
}
17 changes: 13 additions & 4 deletions crates/buzz-core/src/desktop_stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pub struct StopResult {
pub outcome: StopOutcome,
}

fn hex(value: &str, len: usize) -> bool {
pub(crate) fn hex(value: &str, len: usize) -> bool {
value.len() == len
&& value
.bytes()
Expand All @@ -67,7 +67,12 @@ pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
Ok(())
}

fn sign<T: Serialize>(value: &T, keys: &Keys, kind: u32, tags: Vec<Tag>) -> Result<Event, String> {
pub(crate) fn sign<T: Serialize>(
value: &T,
keys: &Keys,
kind: u32,
tags: Vec<Tag>,
) -> Result<Event, String> {
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
Expand All @@ -81,12 +86,16 @@ fn sign<T: Serialize>(value: &T, keys: &Keys, kind: u32, tags: Vec<Tag>) -> Resu
.map_err(|e| e.to_string())
}

fn read<T: serde::de::DeserializeOwned>(
pub(crate) fn read<T: serde::de::DeserializeOwned>(
event: &Event,
keys: &Keys,
kind: u32,
) -> Result<T, String> {
validate_envelope(event)?;
if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) {
validate_envelope(event)?;
} else {
crate::desktop_lifecycle::validate_envelope(event)?;
}
event
.verify()
.map_err(|_| "invalid Desktop Stop signature")?;
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;
pub const KIND_DESKTOP_STOP: u32 = 50180;
/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID.
pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181;
/// Owner-private Start/Restart/status request, separate from legacy Stop.
pub const KIND_DESKTOP_LIFECYCLE: u32 = 50182;
/// Correlated owner-private Desktop lifecycle result.
pub const KIND_DESKTOP_LIFECYCLE_RESULT: u32 = 50183;

/// Kinds whose stored events are readable only by their author.
///
Expand All @@ -148,6 +152,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
KIND_DESKTOP_LIFECYCLE,
KIND_DESKTOP_LIFECYCLE_RESULT,
];

/// Kinds that require a result-level read gate beyond the filter-layer
Expand Down Expand Up @@ -682,6 +688,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
KIND_DESKTOP_LIFECYCLE,
KIND_DESKTOP_LIFECYCLE_RESULT,
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 @@ -10,6 +10,7 @@ pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
pub mod desktop_capabilities;
pub mod desktop_lifecycle;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
Expand Down
29 changes: 24 additions & 5 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(), 48);
assert_eq!(migrations.len(), 49);
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, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)"
"kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183)"
));

// Public push-gateway authority is intentionally deployment-global and
Expand Down Expand Up @@ -2396,6 +2396,8 @@ mod postgres_tests {
(6_u8, 30_182_i32),
(7_u8, 50_180_i32),
(8_u8, 50_181_i32),
(9_u8, 50_182_i32),
(10_u8, 50_183_i32),
] {
sqlx::query(
"INSERT INTO events \
Expand Down Expand Up @@ -2433,7 +2435,9 @@ mod postgres_tests {
(30_182, true),
(30_350, true),
(50_180, true),
(50_181, true)
(50_181, true),
(50_182, true),
(50_183, true)
]
);

Expand All @@ -2460,7 +2464,9 @@ mod postgres_tests {
(30_182, Some(true)),
(30_350, None),
(50_180, Some(true)),
(50_181, Some(true))
(50_181, Some(true)),
(50_182, Some(true)),
(50_183, Some(true))
]
);

Expand Down Expand Up @@ -2507,6 +2513,17 @@ mod postgres_tests {
.await
.unwrap();
assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS");
run_migrations_through(&pool, 48).await.unwrap();
let lifecycle_indexed: i64 = sqlx::query_scalar(
"SELECT count(*) FROM events WHERE kind IN (50182, 50183) AND search_tsv IS NOT NULL",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
lifecycle_indexed, 2,
"0049 must change populated lifecycle FTS"
);

run_migrations(&pool)
.await
Expand All @@ -2528,7 +2545,9 @@ mod postgres_tests {
(30_182, None),
(30_350, None),
(50_180, None),
(50_181, None)
(50_181, None),
(50_182, None),
(50_183, None)
]
);
let gin_exists: bool = sqlx::query_scalar(
Expand Down
Loading
Loading