fix(egress): stamp durable capabilities from the issuing lease (C2 fix round)

Addresses Paul's four C2 findings before C3.

F1 (barrier bypass): register_owner_session/register_owner_bearer re-read the
current generation at registration, so a capability derived from a losing
identity could be stamped with the winning generation and survive the C5
barrier. Both now take &OwnerIdentityEgressLease as a compile-enforced witness
and stamp lease.generation() via a shared register_durable() helper — a bump
between admission and registration leaves a stale stamp that the first
admit_exercise refuses (fail-closed). Huddle registers the session while the
auth lease is still held (before the joined await, not spanning it). Corrects
the DurableRegistry "same lock" and media.rs "no bump can slip" doc claims.
Adds a red-then-green control: admit under gen N, drain bumps to N+1, register,
exercise refuses.

F3 (doctrine): the upload legacy retry re-attached the signed header without a
second admit_exercise. Revalidate before the retry dispatch — two
transmissions, two validations.

F4 (scope): create_auth_event (frontend relay WS) now signs under a per-send
bounded lease, gating reconnection against an in-flight drain. Frontend session
REGISTRATION (capability + native-WS teardown) is explicitly deferred to C6/C7
with the frontend identity store.

F2 (gates): split six files under the desktop file-size ratchet — never
trimmed. owner_identity_egress.rs → directory module with the durable substrate
in durable.rs; relay.rs/media.rs/identity.rs move test modules to sibling
_tests.rs files; card.rs extracts the card-archive cluster to card/archive.rs
and messages.rs the feed-item projection to messages/feed_item.rs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-18 01:09:03 -04:00
parent 02e238b39a
commit fb488a31f6
14 changed files with 1462 additions and 1301 deletions

View File

@@ -859,6 +859,14 @@ pub async fn create_auth_event(
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state.signing_keys()?;
// The relay-WS reconnection handshake signs a NIP-42 auth event under a
// per-send bounded egress lease: reconnection cannot re-authenticate
// mid-transition because the drain refuses new admission (spec — the
// frontend session's authority is gated the same as every other owner
// sign). Frontend session REGISTRATION (capability + native-WS teardown
// handle) defers to C6/C7 with the frontend identity store; this lands the
// spec-mandated admission gate now.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
let tags = vec![
@@ -872,7 +880,8 @@ pub async fn create_auth_event(
.tags(tags)
.sign_with_keys(&keys)
.map_err(|error| format!("sign failed: {error}"))?;
// Sign complete: the lease has served its sign→return window.
drop(lease);
Ok(event.as_json())
})
.await
@@ -915,89 +924,8 @@ pub async fn nip44_decrypt_from_self(
}
#[cfg(test)]
mod nostr_identity_binding_tests {
use super::build_nostr_identity_binding_event;
use crate::nostr_bind;
use nostr::{JsonUtil, Keys};
fn tag_values(event: &nostr::Event) -> Vec<Vec<String>> {
event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect()
}
#[test]
fn build_nostr_identity_binding_event_signs_exact_shape() {
let keys = Keys::generate();
let event = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"123456",
"https://example.com",
"2999-01-01T00:00:00Z",
)
.unwrap();
assert_eq!(event.kind.as_u16(), nostr_bind::KIND);
assert_eq!(event.content, nostr_bind::CONTENT);
assert_eq!(event.pubkey, keys.public_key());
assert!(event.verify_id());
assert!(event.verify_signature());
assert!(nostr::Event::from_json(event.as_json()).is_ok());
let tags = tag_values(&event);
assert!(tags.contains(&vec![
"challenge_id".into(),
"550e8400-e29b-41d4-a716-446655440000".into(),
]));
assert!(tags.contains(&vec![
"nonce".into(),
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(),
]));
assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),]));
assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()]));
assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),]));
assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),]));
assert!(tags.contains(&vec!["version".into(), "1".into(),]));
assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),]));
assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),]));
}
#[test]
fn build_nostr_identity_binding_event_rejects_malformed_verification_code() {
let keys = Keys::generate();
let error = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"12345a",
"https://example.com",
"2999-01-01T00:00:00Z",
)
.unwrap_err();
assert_eq!(error, "verification_code must be exactly 6 digits");
}
#[test]
fn build_nostr_identity_binding_event_rejects_expired_link() {
let keys = Keys::generate();
let error = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"123456",
"https://example.com",
"2000-01-01T00:00:00Z",
)
.unwrap_err();
assert_eq!(error, "expires_at is expired");
}
}
#[path = "identity_binding_tests.rs"]
mod nostr_identity_binding_tests;
#[cfg(test)]
#[path = "identity_key_backup_tests.rs"]

View File

@@ -0,0 +1,84 @@
//! Unit tests for `build_nostr_identity_binding_event`. Split from
//! `identity.rs` for file-size discipline; wired via `#[path]`.
use super::build_nostr_identity_binding_event;
use crate::nostr_bind;
use nostr::{JsonUtil, Keys};
fn tag_values(event: &nostr::Event) -> Vec<Vec<String>> {
event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect()
}
#[test]
fn build_nostr_identity_binding_event_signs_exact_shape() {
let keys = Keys::generate();
let event = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"123456",
"https://example.com",
"2999-01-01T00:00:00Z",
)
.unwrap();
assert_eq!(event.kind.as_u16(), nostr_bind::KIND);
assert_eq!(event.content, nostr_bind::CONTENT);
assert_eq!(event.pubkey, keys.public_key());
assert!(event.verify_id());
assert!(event.verify_signature());
assert!(nostr::Event::from_json(event.as_json()).is_ok());
let tags = tag_values(&event);
assert!(tags.contains(&vec![
"challenge_id".into(),
"550e8400-e29b-41d4-a716-446655440000".into(),
]));
assert!(tags.contains(&vec![
"nonce".into(),
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(),
]));
assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),]));
assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()]));
assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),]));
assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),]));
assert!(tags.contains(&vec!["version".into(), "1".into(),]));
assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),]));
assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),]));
}
#[test]
fn build_nostr_identity_binding_event_rejects_malformed_verification_code() {
let keys = Keys::generate();
let error = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"12345a",
"https://example.com",
"2999-01-01T00:00:00Z",
)
.unwrap_err();
assert_eq!(error, "verification_code must be exactly 6 digits");
}
#[test]
fn build_nostr_identity_binding_event_rejects_expired_link() {
let keys = Keys::generate();
let error = build_nostr_identity_binding_event(
&keys,
"550e8400-e29b-41d4-a716-446655440000",
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
"123456",
"https://example.com",
"2000-01-01T00:00:00Z",
)
.unwrap_err();
assert_eq!(error, "expires_at is expired");
}

View File

@@ -379,9 +379,10 @@ pub(crate) async fn mint_media_get_auth(
}
};
// Issuance runs under a bounded egress lease: signing the bearer is an
// ordinary leased operation (spec L4569-4570). Registering the durable
// capability while the lease is held keeps its generation stamp coherent
// with the state that admitted the sign — no bump can slip between.
// ordinary leased operation (spec L4569-4570). The durable capability is
// stamped with the LEASE's admission generation, not a re-read one — so a
// transition that bumps the generation between admission and registration
// stamps a stale value and the first admit_exercise refuses (fail-closed).
let lease = match crate::owner_identity_egress::try_admit_owner_identity_egress().await {
Ok(lease) => lease,
Err(e) => {
@@ -396,7 +397,7 @@ pub(crate) async fn mint_media_get_auth(
return None;
}
};
let bearer = crate::owner_identity_egress::register_owner_bearer();
let bearer = crate::owner_identity_egress::register_owner_bearer(&lease);
drop(lease);
Some((header, bearer))
}
@@ -472,7 +473,7 @@ async fn do_upload(
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
let keys = state.signing_keys()?;
let event = sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?;
let bearer = crate::owner_identity_egress::register_owner_bearer();
let bearer = crate::owner_identity_egress::register_owner_bearer(&lease);
drop(lease);
(event, bearer)
};
@@ -503,6 +504,10 @@ async fn do_upload(
)
.await?;
if should_retry_legacy_upload(resp.status()) {
// Re-validate before the second transmission: the same signed header is
// resent, and a transition may have superseded the bearer between the
// two dispatches. Two transmissions, two validations.
bearer.admit_exercise()?;
resp = send_upload_attempt(
state,
UploadAttempt {
@@ -837,181 +842,5 @@ pub(super) async fn upload_media_bytes_inner(
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_server_authority_default_ports() {
assert_eq!(
extract_server_authority("https://relay.example.com"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:443"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("http://relay.example.com:80"),
Some("relay.example.com".to_string())
);
}
#[test]
fn test_extract_server_authority_non_default_ports() {
assert_eq!(
extract_server_authority("http://localhost:3000"),
Some("localhost:3000".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:8443"),
Some("relay.example.com:8443".to_string())
);
}
#[test]
fn test_extract_server_authority_ipv6() {
assert_eq!(
extract_server_authority("http://[::1]:3000"),
Some("[::1]:3000".to_string())
);
}
#[test]
fn test_extract_server_authority_invalid() {
assert_eq!(extract_server_authority("not-a-url"), None);
assert_eq!(extract_server_authority(""), None);
}
#[test]
fn test_sign_blossom_get_auth_header_shape() {
let keys = Keys::generate();
let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap();
let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix");
let json = URL_SAFE_NO_PAD.decode(b64).unwrap();
let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap();
assert_eq!(event.kind, Kind::from(24242));
event.verify().expect("valid signature");
let tag = |name: &str| -> Option<String> {
event.tags.iter().find_map(|t| {
let v = t.as_slice();
(v.first().map(String::as_str) == Some(name)).then(|| v[1].clone())
})
};
assert_eq!(tag("t").as_deref(), Some("get"));
assert_eq!(tag("server").as_deref(), Some("localhost:3000"));
// Server-scoped token: no x tag (BUD-01 allows x OR server).
assert!(tag("x").is_none());
let expiration: u64 = tag("expiration").unwrap().parse().unwrap();
let now = Timestamp::now().as_secs();
assert!(expiration > now && expiration <= now + 600);
}
#[test]
fn test_sign_blossom_get_auth_header_invalid_base_url() {
let keys = Keys::generate();
assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err());
}
#[test]
fn test_detect_and_validate_mime_jpeg() {
// Minimal JPEG: SOI + EOI
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg");
}
#[test]
fn test_detect_and_validate_mime_accepts_text_as_octet_stream() {
// Plain text has no magic bytes — infer returns None, so it's accepted
// as opaque binary (served as a download). This is the common Slack case.
let text = b"hello world";
assert_eq!(
detect_and_validate_mime(text).unwrap(),
"application/octet-stream"
);
}
#[test]
fn test_detect_and_validate_mime_rejects_html() {
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
assert!(detect_and_validate_mime(html).is_err());
}
#[test]
fn test_image_sanitizer_bakes_exif_orientation() {
let source = image::RgbImage::from_fn(2, 3, |x, y| {
image::Rgb([(x * 80) as u8, (y * 60) as u8, 32])
});
let mut encoded = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 95)
.encode_image(&source)
.unwrap();
// Minimal little-endian Exif IFD with Orientation=6 (rotate 90°).
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0".to_vec();
exif.extend_from_slice(&[
0x12, 0x01, // Orientation tag
0x03, 0x00, // SHORT
0x01, 0x00, 0x00, 0x00, // count=1
0x06, 0x00, 0x00, 0x00, // value=6
0x00, 0x00, 0x00, 0x00, // next IFD
]);
let segment_len = (exif.len() + 2) as u16;
let mut oriented = encoded[..2].to_vec();
oriented.extend_from_slice(&[0xff, 0xe1]);
oriented.extend_from_slice(&segment_len.to_be_bytes());
oriented.extend_from_slice(&exif);
oriented.extend_from_slice(&encoded[2..]);
let sanitized = sanitize_image_for_upload(oriented, "image/jpeg").unwrap();
let decoded =
image::load_from_memory_with_format(&sanitized, image::ImageFormat::Jpeg).unwrap();
assert_eq!((decoded.width(), decoded.height()), (3, 2));
assert!(!sanitized.windows(6).any(|bytes| bytes == b"Exif\0\0"));
}
#[test]
fn test_animated_png_and_webp_are_not_flattened() {
let mut apng = b"\x89PNG\r\n\x1a\n".to_vec();
apng.extend_from_slice(&8u32.to_be_bytes());
apng.extend_from_slice(b"acTL");
apng.extend_from_slice(&[0; 8]);
apng.extend_from_slice(&[0; 4]);
assert!(is_animated_image(&apng, "image/png"));
assert!(sanitize_image_for_upload(apng, "image/png").is_ok());
let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec();
webp.extend_from_slice(&0u32.to_le_bytes());
assert!(is_animated_image(&webp, "image/webp"));
assert!(sanitize_image_for_upload(webp, "image/webp").is_ok());
}
#[test]
fn test_legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
assert!(should_retry_legacy_upload(
reqwest::StatusCode::METHOD_NOT_ALLOWED
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNPROCESSABLE_ENTITY
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
// Strips directory components and traversal.
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt");
assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx");
// Empty / separator-only falls back.
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("/"), "file");
// Control chars removed.
assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt");
}
}
#[path = "media_tests.rs"]
mod tests;

View File

@@ -0,0 +1,179 @@
//! Unit tests for the media command module. Split from `media.rs` for
//! file-size discipline; wired via `#[path]` from that file's `mod tests`.
use super::*;
#[test]
fn test_extract_server_authority_default_ports() {
assert_eq!(
extract_server_authority("https://relay.example.com"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:443"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("http://relay.example.com:80"),
Some("relay.example.com".to_string())
);
}
#[test]
fn test_extract_server_authority_non_default_ports() {
assert_eq!(
extract_server_authority("http://localhost:3000"),
Some("localhost:3000".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:8443"),
Some("relay.example.com:8443".to_string())
);
}
#[test]
fn test_extract_server_authority_ipv6() {
assert_eq!(
extract_server_authority("http://[::1]:3000"),
Some("[::1]:3000".to_string())
);
}
#[test]
fn test_extract_server_authority_invalid() {
assert_eq!(extract_server_authority("not-a-url"), None);
assert_eq!(extract_server_authority(""), None);
}
#[test]
fn test_sign_blossom_get_auth_header_shape() {
let keys = Keys::generate();
let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap();
let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix");
let json = URL_SAFE_NO_PAD.decode(b64).unwrap();
let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap();
assert_eq!(event.kind, Kind::from(24242));
event.verify().expect("valid signature");
let tag = |name: &str| -> Option<String> {
event.tags.iter().find_map(|t| {
let v = t.as_slice();
(v.first().map(String::as_str) == Some(name)).then(|| v[1].clone())
})
};
assert_eq!(tag("t").as_deref(), Some("get"));
assert_eq!(tag("server").as_deref(), Some("localhost:3000"));
// Server-scoped token: no x tag (BUD-01 allows x OR server).
assert!(tag("x").is_none());
let expiration: u64 = tag("expiration").unwrap().parse().unwrap();
let now = Timestamp::now().as_secs();
assert!(expiration > now && expiration <= now + 600);
}
#[test]
fn test_sign_blossom_get_auth_header_invalid_base_url() {
let keys = Keys::generate();
assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err());
}
#[test]
fn test_detect_and_validate_mime_jpeg() {
// Minimal JPEG: SOI + EOI
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg");
}
#[test]
fn test_detect_and_validate_mime_accepts_text_as_octet_stream() {
// Plain text has no magic bytes — infer returns None, so it's accepted
// as opaque binary (served as a download). This is the common Slack case.
let text = b"hello world";
assert_eq!(
detect_and_validate_mime(text).unwrap(),
"application/octet-stream"
);
}
#[test]
fn test_detect_and_validate_mime_rejects_html() {
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
assert!(detect_and_validate_mime(html).is_err());
}
#[test]
fn test_image_sanitizer_bakes_exif_orientation() {
let source = image::RgbImage::from_fn(2, 3, |x, y| {
image::Rgb([(x * 80) as u8, (y * 60) as u8, 32])
});
let mut encoded = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 95)
.encode_image(&source)
.unwrap();
// Minimal little-endian Exif IFD with Orientation=6 (rotate 90°).
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0".to_vec();
exif.extend_from_slice(&[
0x12, 0x01, // Orientation tag
0x03, 0x00, // SHORT
0x01, 0x00, 0x00, 0x00, // count=1
0x06, 0x00, 0x00, 0x00, // value=6
0x00, 0x00, 0x00, 0x00, // next IFD
]);
let segment_len = (exif.len() + 2) as u16;
let mut oriented = encoded[..2].to_vec();
oriented.extend_from_slice(&[0xff, 0xe1]);
oriented.extend_from_slice(&segment_len.to_be_bytes());
oriented.extend_from_slice(&exif);
oriented.extend_from_slice(&encoded[2..]);
let sanitized = sanitize_image_for_upload(oriented, "image/jpeg").unwrap();
let decoded =
image::load_from_memory_with_format(&sanitized, image::ImageFormat::Jpeg).unwrap();
assert_eq!((decoded.width(), decoded.height()), (3, 2));
assert!(!sanitized.windows(6).any(|bytes| bytes == b"Exif\0\0"));
}
#[test]
fn test_animated_png_and_webp_are_not_flattened() {
let mut apng = b"\x89PNG\r\n\x1a\n".to_vec();
apng.extend_from_slice(&8u32.to_be_bytes());
apng.extend_from_slice(b"acTL");
apng.extend_from_slice(&[0; 8]);
apng.extend_from_slice(&[0; 4]);
assert!(is_animated_image(&apng, "image/png"));
assert!(sanitize_image_for_upload(apng, "image/png").is_ok());
let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec();
webp.extend_from_slice(&0u32.to_le_bytes());
assert!(is_animated_image(&webp, "image/webp"));
assert!(sanitize_image_for_upload(webp, "image/webp").is_ok());
}
#[test]
fn test_legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
assert!(should_retry_legacy_upload(
reqwest::StatusCode::METHOD_NOT_ALLOWED
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNPROCESSABLE_ENTITY
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
// Strips directory components and traversal.
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt");
assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx");
// Empty / separator-only falls back.
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("/"), "file");
// Control chars removed.
assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt");
}

View File

@@ -978,36 +978,9 @@ pub async fn delete_message(
// ── Local helpers ───────────────────────────────────────────────────────────
fn channel_id_from_tags(ev: &nostr::Event) -> Option<String> {
ev.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "h" {
Some(s[1].clone())
} else {
None
}
})
}
mod feed_item;
use feed_item::feed_item_from_event;
fn tags_to_vec(ev: &nostr::Event) -> Vec<Vec<String>> {
ev.tags.iter().map(|t| t.as_slice().to_vec()).collect()
}
fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
let channel_id = channel_id_from_tags(ev);
FeedItemInfo {
id: ev.id.to_hex(),
kind: ev.kind.as_u16() as u32,
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
created_at: ev.created_at.as_secs(),
channel_id,
channel_name: String::new(),
channel_type: None,
tags: tags_to_vec(ev),
category: category.to_string(),
}
}
#[cfg(test)]
#[path = "messages_tests.rs"]
mod tests;

View File

@@ -0,0 +1,35 @@
//! Feed-item projection helpers: turn a raw nostr event into the
//! `FeedItemInfo` wire shape the feed reads return.
use crate::models::FeedItemInfo;
fn channel_id_from_tags(ev: &nostr::Event) -> Option<String> {
ev.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "h" {
Some(s[1].clone())
} else {
None
}
})
}
fn tags_to_vec(ev: &nostr::Event) -> Vec<Vec<String>> {
ev.tags.iter().map(|t| t.as_slice().to_vec()).collect()
}
pub(super) fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
let channel_id = channel_id_from_tags(ev);
FeedItemInfo {
id: ev.id.to_hex(),
kind: ev.kind.as_u16() as u32,
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
created_at: ev.created_at.as_secs(),
channel_id,
channel_name: String::new(),
channel_type: None,
tags: tags_to_vec(ev),
category: category.to_string(),
}
}

View File

@@ -24,7 +24,7 @@
//! It is never logged.
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use tauri::{AppHandle, State};
use super::super::export_util::save_bytes_with_dialog;
@@ -48,6 +48,9 @@ use crate::{
},
};
mod archive;
pub use archive::{list_agent_cards, load_agent_card};
/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation
/// input only: it never participates in the snapshot manifest, PNG chunk,
/// import decoder, or attachment validation. Embedded at compile time for
@@ -92,174 +95,6 @@ pub struct MintedCard {
pub memory_level: MemoryLevel,
}
// ── Card archive ──────────────────────────────────────────────────────────────
/// Sidecar metadata for one archived card PNG. Stored as `<stem>.json` next
/// to `<stem>.agent.png` in the cards dir — two plain files per mint, no
/// shared index to corrupt. Listing scans sidecars; a card whose PNG is
/// missing is skipped rather than failing the whole list.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchivedCardMeta {
/// Unique on-disk PNG file name within the cards dir.
pub stored_file_name: String,
/// Suggested save-as name, e.g. `eva.agent.png`.
pub file_name: String,
/// The id the card was minted for (instance pubkey or definition slug).
pub agent_id: String,
pub agent_name: String,
pub designer_notes: String,
pub locked: bool,
/// Memory embedded in this card's snapshot. Defaults to `None` when the
/// sidecar predates the field — every pre-field mint was minted with
/// `MemoryLevel::None` (it was structural), so the default is honest.
#[serde(default)]
pub memory_level: MemoryLevel,
/// ISO-8601 mint timestamp.
pub minted_at: String,
/// Small JPEG preview for gallery grids, base64. Populated by
/// `list_agent_cards` from the sidecar thumb file — never stored in the
/// JSON sidecar itself.
#[serde(default, skip_deserializing)]
pub thumb_jpeg_base64: Option<String>,
}
fn cards_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards");
std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?;
Ok(dir)
}
/// Persist a freshly minted card to the archive. Failures are surfaced to the
/// caller (which logs and continues) — an archive write must never fail a
/// mint the user already paid for.
fn archive_minted_card(
app: &AppHandle,
agent_id: &str,
agent_name: &str,
card: &MintedCard,
bytes: &[u8],
) -> Result<ArchivedCardMeta, String> {
let dir = cards_dir(app)?;
let stem = format!(
"{}-{}",
crate::util::slugify(agent_name, "agent", 50),
uuid::Uuid::new_v4()
);
let stored_file_name = format!("{stem}.agent.png");
let meta = ArchivedCardMeta {
stored_file_name: stored_file_name.clone(),
file_name: card.file_name.clone(),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
designer_notes: card.designer_notes.clone(),
locked: card.locked,
memory_level: card.memory_level,
minted_at: crate::util::now_iso(),
thumb_jpeg_base64: None,
};
// PNG first, sidecar second: a crash between the two leaves an orphaned
// PNG (invisible to the list), never a sidecar pointing at nothing.
std::fs::write(dir.join(&stored_file_name), bytes)
.map_err(|e| format!("failed to write archived card: {e}"))?;
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("failed to serialize card metadata: {e}"))?;
std::fs::write(dir.join(format!("{stem}.json")), meta_json)
.map_err(|e| format!("failed to write card metadata: {e}"))?;
// Thumb last and best-effort: the gallery grid falls back to lazy
// full-card loading for a card whose thumb is missing.
if let Ok(thumb) = encode_card_thumb(bytes) {
let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb);
}
Ok(meta)
}
/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card
/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to
/// draw a grid tile is waste.
fn encode_card_thumb(bytes: &[u8]) -> Result<Vec<u8>, String> {
const THUMB_WIDTH: u32 = 300;
let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?;
let scale = THUMB_WIDTH as f64 / img.width() as f64;
let thumb = img.resize(
THUMB_WIDTH,
(img.height() as f64 * scale).round().max(1.0) as u32,
image::imageops::FilterType::Triangle,
);
let mut out = Vec::new();
// JPEG has no alpha; cards are opaque, so flatten unconditionally.
let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8());
rgb.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.map_err(|e| format!("thumb encode: {e}"))?;
Ok(out)
}
/// Reject any archive file name that could escape the cards dir or name a
/// non-archive file. Archive names are generated by `archive_minted_card`
/// (slug + UUID), so a strict shape check loses nothing legitimate.
fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> {
let valid = stored_file_name.ends_with(".agent.png")
&& !stored_file_name.contains(['/', '\\'])
&& !stored_file_name.contains("..");
if !valid {
return Err("Invalid archived card file name.".to_string());
}
Ok(())
}
/// List all archived cards, newest first.
#[tauri::command]
pub fn list_agent_cards(app: AppHandle) -> Result<Vec<ArchivedCardMeta>, String> {
let dir = cards_dir(&app)?;
let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?;
let mut cards = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<ArchivedCardMeta>(&content) else {
// A malformed sidecar hides one card, never the archive.
eprintln!(
"buzz-desktop: card-archive: skipping malformed sidecar {}",
path.display()
);
continue;
};
let mut meta = meta;
if validate_archive_file_name(&meta.stored_file_name).is_ok()
&& dir.join(&meta.stored_file_name).is_file()
{
// Attach the pre-rendered grid thumb when present (best-effort).
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg")))
.ok()
.map(|b| STANDARD.encode(&b));
}
cards.push(meta);
}
}
// ISO-8601 sorts lexicographically; newest first.
cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at));
Ok(cards)
}
/// Load one archived card's PNG bytes as base64, keyed by its stored file
/// name (as returned by `list_agent_cards`).
#[tauri::command]
pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result<String, String> {
validate_archive_file_name(&stored_file_name)?;
let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name))
.map_err(|e| format!("failed to read archived card: {e}"))?;
Ok(STANDARD.encode(&bytes))
}
// ── Key resolution ────────────────────────────────────────────────────────────
/// Pure layering: global env < persona env < agent record env, then the
@@ -854,7 +689,7 @@ pub async fn mint_agent_card(
// Archive best-effort: the mint is already paid for and verified, so a
// failed archive write logs and continues — it never fails the mint.
if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) {
if let Err(e) = archive::archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) {
eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}");
}

View File

@@ -0,0 +1,230 @@
//! Card archive: on-disk persistence for minted trading cards.
//!
//! Each mint writes two plain files to the cards dir — `<stem>.agent.png`
//! and a `<stem>.json` sidecar — plus a best-effort `<stem>.thumb.jpg` for
//! gallery grids. There is no shared index to corrupt: listing scans
//! sidecars, and a card whose PNG is missing is skipped rather than failing
//! the whole list.
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use super::MintedCard;
use crate::managed_agents::agent_snapshot::MemoryLevel;
/// Sidecar metadata for one archived card PNG. Stored as `<stem>.json` next
/// to `<stem>.agent.png` in the cards dir — two plain files per mint, no
/// shared index to corrupt. Listing scans sidecars; a card whose PNG is
/// missing is skipped rather than failing the whole list.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchivedCardMeta {
/// Unique on-disk PNG file name within the cards dir.
pub stored_file_name: String,
/// Suggested save-as name, e.g. `eva.agent.png`.
pub file_name: String,
/// The id the card was minted for (instance pubkey or definition slug).
pub agent_id: String,
pub agent_name: String,
pub designer_notes: String,
pub locked: bool,
/// Memory embedded in this card's snapshot. Defaults to `None` when the
/// sidecar predates the field — every pre-field mint was minted with
/// `MemoryLevel::None` (it was structural), so the default is honest.
#[serde(default)]
pub memory_level: MemoryLevel,
/// ISO-8601 mint timestamp.
pub minted_at: String,
/// Small JPEG preview for gallery grids, base64. Populated by
/// `list_agent_cards` from the sidecar thumb file — never stored in the
/// JSON sidecar itself.
#[serde(default, skip_deserializing)]
pub thumb_jpeg_base64: Option<String>,
}
fn cards_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards");
std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?;
Ok(dir)
}
/// Persist a freshly minted card to the archive. Failures are surfaced to the
/// caller (which logs and continues) — an archive write must never fail a
/// mint the user already paid for.
pub(super) fn archive_minted_card(
app: &AppHandle,
agent_id: &str,
agent_name: &str,
card: &MintedCard,
bytes: &[u8],
) -> Result<ArchivedCardMeta, String> {
let dir = cards_dir(app)?;
let stem = format!(
"{}-{}",
crate::util::slugify(agent_name, "agent", 50),
uuid::Uuid::new_v4()
);
let stored_file_name = format!("{stem}.agent.png");
let meta = ArchivedCardMeta {
stored_file_name: stored_file_name.clone(),
file_name: card.file_name.clone(),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
designer_notes: card.designer_notes.clone(),
locked: card.locked,
memory_level: card.memory_level,
minted_at: crate::util::now_iso(),
thumb_jpeg_base64: None,
};
// PNG first, sidecar second: a crash between the two leaves an orphaned
// PNG (invisible to the list), never a sidecar pointing at nothing.
std::fs::write(dir.join(&stored_file_name), bytes)
.map_err(|e| format!("failed to write archived card: {e}"))?;
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("failed to serialize card metadata: {e}"))?;
std::fs::write(dir.join(format!("{stem}.json")), meta_json)
.map_err(|e| format!("failed to write card metadata: {e}"))?;
// Thumb last and best-effort: the gallery grid falls back to lazy
// full-card loading for a card whose thumb is missing.
if let Ok(thumb) = encode_card_thumb(bytes) {
let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb);
}
Ok(meta)
}
/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card
/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to
/// draw a grid tile is waste.
fn encode_card_thumb(bytes: &[u8]) -> Result<Vec<u8>, String> {
const THUMB_WIDTH: u32 = 300;
let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?;
let scale = THUMB_WIDTH as f64 / img.width() as f64;
let thumb = img.resize(
THUMB_WIDTH,
(img.height() as f64 * scale).round().max(1.0) as u32,
image::imageops::FilterType::Triangle,
);
let mut out = Vec::new();
// JPEG has no alpha; cards are opaque, so flatten unconditionally.
let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8());
rgb.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.map_err(|e| format!("thumb encode: {e}"))?;
Ok(out)
}
/// Reject any archive file name that could escape the cards dir or name a
/// non-archive file. Archive names are generated by `archive_minted_card`
/// (slug + UUID), so a strict shape check loses nothing legitimate.
fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> {
let valid = stored_file_name.ends_with(".agent.png")
&& !stored_file_name.contains(['/', '\\'])
&& !stored_file_name.contains("..");
if !valid {
return Err("Invalid archived card file name.".to_string());
}
Ok(())
}
/// List all archived cards, newest first.
#[tauri::command]
pub fn list_agent_cards(app: AppHandle) -> Result<Vec<ArchivedCardMeta>, String> {
let dir = cards_dir(&app)?;
let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?;
let mut cards = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<ArchivedCardMeta>(&content) else {
// A malformed sidecar hides one card, never the archive.
eprintln!(
"buzz-desktop: card-archive: skipping malformed sidecar {}",
path.display()
);
continue;
};
let mut meta = meta;
if validate_archive_file_name(&meta.stored_file_name).is_ok()
&& dir.join(&meta.stored_file_name).is_file()
{
// Attach the pre-rendered grid thumb when present (best-effort).
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg")))
.ok()
.map(|b| STANDARD.encode(&b));
}
cards.push(meta);
}
}
// ISO-8601 sorts lexicographically; newest first.
cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at));
Ok(cards)
}
/// Load one archived card's PNG bytes as base64, keyed by its stored file
/// name (as returned by `list_agent_cards`).
#[tauri::command]
pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result<String, String> {
validate_archive_file_name(&stored_file_name)?;
let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name))
.map_err(|e| format!("failed to read archived card: {e}"))?;
Ok(STANDARD.encode(&bytes))
}
#[cfg(test)]
mod tests {
use super::validate_archive_file_name;
use super::{ArchivedCardMeta, MemoryLevel};
#[test]
fn archive_file_name_validation_rejects_escapes() {
assert!(validate_archive_file_name("eva-1234.agent.png").is_ok());
for bad in [
"../escape.agent.png",
"sub/dir.agent.png",
"sub\\dir.agent.png",
"not-a-card.png",
"plain.json",
"",
] {
assert!(
validate_archive_file_name(bad).is_err(),
"expected rejection: {bad:?}"
);
}
}
#[test]
fn archived_sidecar_without_memory_level_defaults_to_none() {
// Every mint before the memory option existed embedded
// MemoryLevel::None structurally, so old sidecars (no memoryLevel
// field) must deserialize to None — the gallery's disclosure depends
// on this being honest.
let legacy = r#"{
"storedFileName": "eva-1234.agent.png",
"fileName": "eva.agent.png",
"agentId": "abc",
"agentName": "Eva",
"designerNotes": "",
"locked": false,
"mintedAt": "2026-07-28T00:00:00Z"
}"#;
let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::None);
let with_level = legacy.replace(
"\"locked\": false,",
"\"locked\": false, \"memoryLevel\": \"everything\",",
);
let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::Everything);
}
}

View File

@@ -4,24 +4,6 @@
use super::*;
use std::collections::BTreeMap;
#[test]
fn archive_file_name_validation_rejects_escapes() {
assert!(validate_archive_file_name("eva-1234.agent.png").is_ok());
for bad in [
"../escape.agent.png",
"sub/dir.agent.png",
"sub\\dir.agent.png",
"not-a-card.png",
"plain.json",
"",
] {
assert!(
validate_archive_file_name(bad).is_err(),
"expected rejection: {bad:?}"
);
}
}
#[test]
fn card_template_decodes_with_expected_shape() {
// The embedded template is generation input only, but a corrupt or
@@ -358,31 +340,6 @@ fn save_rejects_plain_png_without_snapshot_chunk() {
assert!(decode_snapshot_png(&png).is_err());
}
#[test]
fn archived_sidecar_without_memory_level_defaults_to_none() {
// Every mint before the memory option existed embedded MemoryLevel::None
// structurally, so old sidecars (no memoryLevel field) must deserialize
// to None — the gallery's disclosure depends on this being honest.
let legacy = r#"{
"storedFileName": "eva-1234.agent.png",
"fileName": "eva.agent.png",
"agentId": "abc",
"agentName": "Eva",
"designerNotes": "",
"locked": false,
"mintedAt": "2026-07-28T00:00:00Z"
}"#;
let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::None);
let with_level = legacy.replace(
"\"locked\": false,",
"\"locked\": false, \"memoryLevel\": \"everything\",",
);
let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap();
assert_eq!(meta.memory_level, MemoryLevel::Everything);
}
#[test]
fn minted_card_serializes_memory_level_snake_case_value() {
// The TS layer narrows on the exact wire strings "none"/"core"/

View File

@@ -127,9 +127,19 @@ pub(crate) async fn connect_audio_relay(
.send(WsMsg::Text(auth_msg.to_string().into()))
.await
.map_err(|e| format!("send auth: {e}"))?;
// Sign → auth-send complete: the bounded lease has served its purpose.
// Drop it before awaiting `joined` so it does not span the wait (spec
// L4505-4508); the session capability below carries authority forward.
// Register the durable session capability WHILE the auth lease is still
// held, so it stamps the generation that lease was admitted under — the
// generation the NIP-42 auth was signed under. Registering after the
// `joined` await (where the lease is already dropped) would re-read a
// generation that a concurrent transition may have bumped, stamping the
// winning generation onto authority derived from the losing identity — a
// barrier bypass. Its cancellation token is the C5 barrier's teardown
// handle; the send task validates it before every frame.
let cancel = CancellationToken::new();
let session = crate::owner_identity_egress::register_owner_session(&auth_lease, cancel.clone());
// Sign → auth-send → register complete: the bounded lease has served its
// purpose. Drop it before awaiting `joined` so it does not span the wait
// (spec L4505-4508); the session capability carries authority forward.
drop(auth_lease);
let initial_peers: Vec<(u8, String)> = tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
@@ -171,14 +181,7 @@ pub(crate) async fn connect_audio_relay(
.map_err(|_| "timeout waiting for joined from relay".to_string())?
.map_err(|e: String| e)?;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
// Register the durable session capability, stamped with the current
// identity-persistence generation and carrying THIS connection's
// cancellation token as its revocation handle. The send task validates it
// before every frame; the C5 coordinator barrier invokes the registered
// token to tear the socket down when an identity transition supersedes it.
let session = crate::owner_identity_egress::register_owner_session(cancel.clone());
let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(50);
let output_device_name = state
.huddle_audio

View File

@@ -0,0 +1,494 @@
//! Durable owner-identity capabilities (C2 / P30-C1).
//!
//! Split from the parent module (which owns the bounded per-send lease, the
//! registry, and the coordinator drain/latch) so each file stays within the
//! desktop file-size discipline. This half is the DURABLE capability substrate:
//! authority that outlives the bounded lease that derived it (long-lived
//! sessions, pre-minted bearers). It reads the parent's registry generation
//! and admission state through `super::` — the two halves share ONE registry.
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::{LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
use super::{lock_inner, IdentityPersistenceState, OwnerIdentityEgressLease, REGISTRY};
/// A generation-stamped, registry-tracked owner-identity capability whose
/// authority OUTLIVES the bounded lease that derived it.
///
/// The bounded [`OwnerIdentityEgressLease`] above covers authority derived and
/// consumed inside one sign → auth → transmit window. But two owner-key
/// operations mint authority that a LATER, separate operation exercises:
///
/// - **Sessions** ([`SessionPolicy`]) — the huddle audio socket authenticates
/// ONCE with a NIP-42 event, then a long-lived task emits unsigned frames
/// over the established peer indefinitely; the frontend relay WS is the same
/// shape (`create_auth_event` signs the handshake, later frames ride the
/// connection). Cloning the owner keys before the transition cannot express
/// that a later unsigned frame inherits pre-transition authority.
/// - **Bearers** ([`BearerPolicy`]) — `mint_media_get_auth` /
/// `sign_blossom_upload_auth` sign a server-scoped Blossom header that
/// callers attach at LATER HTTP transmissions, up to ten minutes after
/// issuance. The bearer TTL is wider than a transition, so TTL is not a
/// substitute for invalidation.
///
/// Every durable capability is REGISTERED in this same egress registry and
/// STAMPED with the identity-persistence generation current at issuance
/// (issuance itself runs under a bounded lease — the signing that derives the
/// capability is an ordinary leased operation). Every transmission over it
/// validates, immediately before the irreversible boundary (frame send /
/// header attach), BOTH current egress admission AND
/// `capability_generation == current identity-persistence generation`
/// ([`OwnerIdentityCapability::admit_exercise`]) — a stale capability is
/// refused with zero bytes sent. The type is the only carrier of that stamped
/// generation, so no exercise site can skip the check (there is no raw handle
/// to exercise).
///
/// The registry additionally holds each capability's REVOCATION HANDLE (the
/// session cancellation token; the bearer's registry id for invalidation), so
/// the C5 coordinator barrier only *invokes* what C2 already registered — it
/// never retrofits the registry schema. Registering the teardown authority is
/// substrate (C2); invoking it at the transition barrier is C5.
#[derive(Debug)]
#[must_use = "a durable owner-identity capability must be validated \
(admit_exercise) immediately before every transmission over it"]
pub struct OwnerIdentityCapability<P: CapabilityPolicy> {
/// Registry id — the key under which this capability's revocation handle
/// lives, so the C5 barrier can invalidate it by generation.
id: u64,
/// The identity-persistence generation current when this capability was
/// issued. Exercise is refused once the registry generation advances past
/// it.
generation: u64,
_policy: std::marker::PhantomData<P>,
}
/// A policy distinguishing the durable capability kinds. Zero-sized markers —
/// the shared behavior (registration, generation-stamp, exercise validation,
/// revocation-handle storage) lives on [`OwnerIdentityCapability`]; the policy
/// only names the kind so the constructor inventory and the registry's
/// per-kind revocation are type-directed.
pub trait CapabilityPolicy: std::fmt::Debug + private::Sealed {
/// A human label for diagnostics and the closed-world inventory.
const KIND: &'static str;
}
/// Authenticated-connection authority (huddle audio socket, frontend relay
/// WS). The registered revocation handle is a [`CancellationToken`] whose
/// cancellation tears the connection down.
#[derive(Debug)]
pub struct SessionPolicy;
/// Pre-minted bearer authority (Blossom `t=get`/`t=upload` headers). The
/// registered revocation handle is the registry id; invalidation removes the
/// entry so a later attach fails admission.
#[derive(Debug)]
pub struct BearerPolicy;
impl CapabilityPolicy for SessionPolicy {
const KIND: &'static str = "session";
}
impl CapabilityPolicy for BearerPolicy {
const KIND: &'static str = "bearer";
}
mod private {
pub trait Sealed {}
impl Sealed for super::SessionPolicy {}
impl Sealed for super::BearerPolicy {}
}
/// The revocation authority for one registered durable capability, invoked by
/// the C5 coordinator barrier to tear down old-generation authority before the
/// journal write + durable dispatch.
enum RevocationHandle {
/// Cancel the connection (huddle socket / frontend WS teardown path).
Session(CancellationToken),
/// Bearer invalidation is by-entry: removing the registry entry is the
/// invalidation, so no side-effecting handle is needed. The variant exists
/// so the registry records the capability's kind for the per-kind barrier.
Bearer,
}
/// Registry of live durable capabilities, keyed by capability id, under its
/// own [`Mutex`] (`DURABLE`) — separate from the bounded-lease state
/// (`REGISTRY.inner`). Registration is therefore NOT lock-linearized against
/// generation bumps: a bump can slip between a lease's admission and its
/// capability's registration. The safety guarantee is not lock ordering but
/// EXERCISE-TIME validation — each capability stamps its issuing lease's
/// generation and [`OwnerIdentityCapability::admit_exercise`] refuses once the
/// current generation advances past it, so a capability registered across a
/// bump fails closed on first use.
#[derive(Default)]
struct DurableRegistry {
/// Next capability id. Monotonic; ids are never reused.
next_id: u64,
/// Live capabilities: id → (issued generation, revocation handle).
entries: HashMap<u64, (u64, RevocationHandle)>,
}
static DURABLE: LazyLock<Mutex<DurableRegistry>> =
LazyLock::new(|| Mutex::new(DurableRegistry::default()));
fn lock_durable() -> std::sync::MutexGuard<'static, DurableRegistry> {
DURABLE.lock().unwrap_or_else(|p| p.into_inner())
}
/// Clear all registered durable capabilities. Called by the parent's
/// `reset_registry_for_test` so a test starts from an empty registry. `next_id`
/// is monotonic and never resets, mirroring the generation.
#[cfg(test)]
pub(super) fn reset_for_test() {
lock_durable().entries.clear();
}
impl<P: CapabilityPolicy> OwnerIdentityCapability<P> {
/// The identity-persistence generation this capability was issued under.
// Consumed by C5 (barrier introspection) and the C2 tests; remove allow
// when a production caller reads it.
#[allow(dead_code)]
pub fn generation(&self) -> u64 {
self.generation
}
/// Validate this capability immediately before an irreversible
/// transmission over it (frame send / header attach). Succeeds only when
/// egress admission is `Live` AND the stamped generation still equals the
/// current identity-persistence generation. A stale or drained capability
/// is refused so the caller sends zero bytes.
///
/// This is a cheap compare, not a re-authentication: the durable
/// capability already proved identity at issuance; exercise only confirms
/// that proof has not been superseded by a transition.
pub fn admit_exercise(&self) -> Result<(), String> {
let inner = lock_inner();
if inner.state != IdentityPersistenceState::Live {
return Err(format!(
"owner-identity {} capability cannot transmit: egress is {:?}",
P::KIND,
inner.state
));
}
let current = REGISTRY.generation.load(Ordering::Acquire);
if self.generation != current {
return Err(format!(
"owner-identity {} capability is stale (issued under generation \
{}, current {}); the identity transitioned and this authority \
was revoked",
P::KIND,
self.generation,
current
));
}
Ok(())
}
}
impl<P: CapabilityPolicy> Drop for OwnerIdentityCapability<P> {
fn drop(&mut self) {
// Dropping the capability handle deregisters it — a session whose task
// has ended or a bearer no longer attachable must not linger as a
// revocation target. Barrier invalidation (C5) also removes entries;
// the id is unique and never reused, so a double-remove is a no-op.
lock_durable().entries.remove(&self.id);
}
}
/// Issue a durable owner-identity session capability, registering its
/// cancellation token so the C5 barrier can tear the connection down.
///
/// Takes the issuing [`OwnerIdentityEgressLease`] as a witness and stamps the
/// generation THAT LEASE was admitted under — never a freshly re-read one. A
/// bump can slip between admission and registration (`begin_egress_drain`
/// advances the generation immediately, before in-flight leases drain), so
/// re-reading here would stamp the winning generation onto authority derived
/// from the losing identity — a barrier bypass. Stamping the lease generation
/// fails closed instead: if a bump slipped in, the stamp is already stale and
/// the first `admit_exercise` refuses.
///
/// Call this AFTER the authenticating sign/auth has completed under the lease
/// (issuance is an ordinary leased operation, spec L4569–4570) and the
/// connection is established. The lease may already be dropped — its generation
/// is copied here — but taking it by reference makes leased issuance
/// compile-enforced, not conventional.
pub fn register_owner_session(
lease: &OwnerIdentityEgressLease,
cancel: CancellationToken,
) -> OwnerIdentityCapability<SessionPolicy> {
register_durable(lease.generation(), RevocationHandle::Session(cancel))
}
/// Issue a durable owner-identity bearer capability, registering it so the C5
/// barrier can invalidate it by generation.
///
/// Stamps the issuing [`OwnerIdentityEgressLease`]'s generation, not a re-read
/// one — see [`register_owner_session`] for why re-reading is a barrier bypass.
///
/// Call this immediately after minting the bearer header under the lease. Every
/// attach site validates the returned capability via
/// [`OwnerIdentityCapability::admit_exercise`] before the HTTP dispatch.
pub fn register_owner_bearer(
lease: &OwnerIdentityEgressLease,
) -> OwnerIdentityCapability<BearerPolicy> {
register_durable(lease.generation(), RevocationHandle::Bearer)
}
/// Register a durable capability stamped with `generation` and carrying
/// `handle` as its revocation authority. Shared by both constructors so the
/// id allocation and registry insert live in one place.
fn register_durable<P: CapabilityPolicy>(
generation: u64,
handle: RevocationHandle,
) -> OwnerIdentityCapability<P> {
let mut durable = lock_durable();
let id = durable.next_id;
durable.next_id += 1;
durable.entries.insert(id, (generation, handle));
OwnerIdentityCapability {
id,
generation,
_policy: std::marker::PhantomData,
}
}
/// Revoke every durable capability issued under a generation older than
/// `winning_generation`: cancel old sessions (invoking each registered
/// [`CancellationToken`]) and drop old bearers (removing their entries so a
/// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the
/// number of capabilities revoked.
///
/// The C5 coordinator barrier calls this after
/// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write +
/// durable B dispatch, so no old-generation session or bearer can transmit
/// across the durable boundary. It only *invokes* the handles C2 registered.
// Consumed by C5 (P25/P28 coordinator barrier); remove allow when C5 lands.
#[allow(dead_code)]
pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize {
let mut durable = lock_durable();
let stale: Vec<u64> = durable
.entries
.iter()
.filter(|(_, (gen, _))| *gen < winning_generation)
.map(|(id, _)| *id)
.collect();
for id in &stale {
if let Some((_, RevocationHandle::Session(cancel))) = durable.entries.remove(id) {
cancel.cancel();
}
}
stale.len()
}
/// The number of live durable capabilities registered. Used by the
/// registration-completeness assertion (C2) to prove every issued
/// session/bearer is present with a revocation handle the C5 barrier can
/// invoke.
// Consumed by C5 and the C2 registration-completeness tests; remove allow when
// a production caller lands.
#[allow(dead_code)]
pub fn live_durable_capability_count() -> usize {
lock_durable().entries.len()
}
#[cfg(test)]
mod tests {
use super::super::{
begin_egress_drain, current_identity_persistence_generation, latch_identity_indeterminate,
resume_egress_live,
};
use super::*;
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = super::super::EGRESS_REGISTRY_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
super::super::reset_registry_for_test();
g
}
/// A bare owner-identity lease admitted under the current generation, for
/// registering durable capabilities in tests.
fn lease() -> OwnerIdentityEgressLease {
super::super::admit_owner_identity_after_wait().expect("test lease admits when live")
}
#[test]
fn durable_capabilities_stamp_the_current_generation() {
let _g = guard();
let session = register_owner_session(&lease(), CancellationToken::new());
let bearer = register_owner_bearer(&lease());
let current = current_identity_persistence_generation();
assert_eq!(session.generation(), current);
assert_eq!(bearer.generation(), current);
}
#[test]
fn durable_capability_exercise_admits_when_live_and_current() {
let _g = guard();
let session = register_owner_session(&lease(), CancellationToken::new());
let bearer = register_owner_bearer(&lease());
assert!(session.admit_exercise().is_ok());
assert!(bearer.admit_exercise().is_ok());
}
// §7 revocation schedule (session): a session issued under A cannot
// transmit after the generation advances to B — the frame send is refused
// with zero bytes. Drives the registry generation directly (C1 drain-test
// pattern); no production transition driver exists until C5.
#[test]
fn stale_session_exercise_refuses_after_a_generation_bump() {
let _g = guard();
let session = register_owner_session(&lease(), CancellationToken::new());
// A transition drains then resumes at the winning generation.
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
session.admit_exercise().is_err(),
"a session stamped under A must refuse to transmit after B wins"
);
}
// §7 revocation schedule (bearer): same stale-generation control for the
// pre-minted bearer attach path.
#[test]
fn stale_bearer_exercise_refuses_after_a_generation_bump() {
let _g = guard();
let bearer = register_owner_bearer(&lease());
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
bearer.admit_exercise().is_err(),
"a bearer minted under A must refuse to attach after B wins"
);
}
// F1 regression: a capability REGISTERED after a bump but derived from a
// lease admitted BEFORE it stamps the lease's (losing) generation, not the
// winning one — so it fails closed. This is the barrier-bypass control:
// before the stamp-from-lease fix, registration re-read the winning
// generation and the capability survived the barrier and admitted under B.
#[test]
fn capability_registered_across_a_bump_stamps_the_losing_generation() {
let _g = guard();
// Lease admitted under the losing generation A.
let lease = lease();
// A transition begins and bumps the generation to B before the lease
// drains (begin_egress_drain advances immediately).
let winning = begin_egress_drain().unwrap();
// Registration happens now, under the pre-bump lease.
let bearer = register_owner_bearer(&lease);
assert_eq!(
bearer.generation(),
winning - 1,
"the capability stamps the lease's losing generation, not the winning one"
);
// The barrier revokes everything older than the winner — this bearer
// is caught, not bypassed.
assert_eq!(
revoke_durable_capabilities_before(winning),
1,
"the across-a-bump capability is revoked by the barrier"
);
// And even had it survived, exercise refuses once the state is Live
// again: its stamp is stale.
resume_egress_live();
drop(lease);
assert!(
bearer.admit_exercise().is_err(),
"a capability derived from the losing identity must refuse to transmit"
);
}
// Stale-capability ZERO-BYTES control (Paul's condition a): exercise
// validation fails BEFORE any transmission, for both durable kinds, under
// both a generation mismatch and the drain/latch state — so no site can
// send a byte on stale authority.
#[test]
fn durable_exercise_refuses_while_draining_and_while_latched() {
let _g = guard();
let session = register_owner_session(&lease(), CancellationToken::new());
let bearer = register_owner_bearer(&lease());
begin_egress_drain().unwrap();
assert!(
session.admit_exercise().is_err(),
"draining refuses session"
);
assert!(bearer.admit_exercise().is_err(), "draining refuses bearer");
latch_identity_indeterminate();
assert!(session.admit_exercise().is_err(), "latch refuses session");
assert!(bearer.admit_exercise().is_err(), "latch refuses bearer");
}
// The C5 barrier cancels old-generation sessions and drops old bearers,
// and does NOT touch capabilities issued at the winning generation.
#[test]
fn barrier_revokes_old_generation_capabilities_only() {
let _g = guard();
let old_session_cancel = CancellationToken::new();
let old_session = register_owner_session(&lease(), old_session_cancel.clone());
let _old_bearer = register_owner_bearer(&lease());
assert_eq!(live_durable_capability_count(), 2);
// Transition to B.
let winning = begin_egress_drain().unwrap();
resume_egress_live();
// A capability issued at the winning generation survives the barrier.
let new_session = register_owner_session(&lease(), CancellationToken::new());
let revoked = revoke_durable_capabilities_before(winning);
assert_eq!(revoked, 2, "both old-generation capabilities revoked");
assert!(
old_session_cancel.is_cancelled(),
"the old session's registered token was invoked"
);
assert_eq!(
live_durable_capability_count(),
1,
"only the winning-generation capability remains"
);
assert!(
new_session.admit_exercise().is_ok(),
"the winning-generation session still transmits"
);
// old_session is now deregistered by the barrier; dropping it is a
// no-op remove.
drop(old_session);
}
// Registration-completeness assertion (Paul's condition b): every issued
// durable capability is present in the registry with a revocation handle
// the C5 barrier can invoke, and dropping the handle deregisters it so a
// dead session/bearer is never a stale revocation target.
#[test]
fn issued_capabilities_are_registered_and_deregister_on_drop() {
let _g = guard();
assert_eq!(live_durable_capability_count(), 0);
let session = register_owner_session(&lease(), CancellationToken::new());
assert_eq!(live_durable_capability_count(), 1);
let bearer = register_owner_bearer(&lease());
assert_eq!(live_durable_capability_count(), 2);
drop(session);
assert_eq!(live_durable_capability_count(), 1, "session deregistered");
drop(bearer);
assert_eq!(live_durable_capability_count(), 0, "bearer deregistered");
}
// A no-transition control: with no generation bump, a registered
// capability keeps transmitting and the barrier revokes nothing.
#[test]
fn no_transition_leaves_durable_capabilities_intact() {
let _g = guard();
let session = register_owner_session(&lease(), CancellationToken::new());
let current = current_identity_persistence_generation();
assert_eq!(
revoke_durable_capabilities_before(current),
0,
"nothing older than the current generation to revoke"
);
assert!(
session.admit_exercise().is_ok(),
"with no transition the session still transmits"
);
}
}

View File

@@ -69,6 +69,9 @@
//! - `managed_agents::persona_events`, `commands::personas::sharing`,
//! `commands::channels` (×3), and the huddle STT task (per-send inline
//! owner lease).
//! - `commands::identity::create_auth_event` — the relay-WS reconnection
//! NIP-42 handshake (per-send lease; frontend session registration defers to
//! C6/C7 with the frontend identity store).
//! - **Durable bearers** — `commands::media::mint_media_get_auth` (Blossom
//! `t=get`) and the `do_upload` `t=upload` mint sign under a bounded lease
//! and register an [`OwnerIdentityCapability<BearerPolicy>`]; the four
@@ -87,12 +90,10 @@
//! `ManagedAgentKeyed` sites).
//! 5. the four `project_git_workflow` PR-status/merge sends (items 5–8).
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
/// Admission state of owner-identity egress — the third recovery state
/// (`Indeterminate`) extends the existing `AppState::identity_lost` /
@@ -199,8 +200,8 @@ pub struct OwnerIdentityEgressLease {
impl OwnerIdentityEgressLease {
/// The identity-persistence generation this lease was admitted under.
// Consumed by C5 (P25/P28 coordinator); remove allow when C5 lands.
#[allow(dead_code)]
/// Stamped onto durable capabilities issued under this lease so they fail
/// closed if a transition bumped the generation after admission.
pub fn generation(&self) -> u64 {
self.generation
}
@@ -469,259 +470,19 @@ fn admit_managed_agent_after_wait() -> Result<ManagedAgentEgressLease, String> {
}
}
// ---------------------------------------------------------------------------
// Durable owner-identity capabilities (C2 / P30-C1)
// ---------------------------------------------------------------------------
/// A generation-stamped, registry-tracked owner-identity capability whose
/// authority OUTLIVES the bounded lease that derived it.
///
/// The bounded [`OwnerIdentityEgressLease`] above covers authority derived and
/// consumed inside one sign → auth → transmit window. But two owner-key
/// operations mint authority that a LATER, separate operation exercises:
///
/// - **Sessions** ([`SessionPolicy`]) — the huddle audio socket authenticates
/// ONCE with a NIP-42 event, then a long-lived task emits unsigned frames
/// over the established peer indefinitely; the frontend relay WS is the same
/// shape (`create_auth_event` signs the handshake, later frames ride the
/// connection). Cloning the owner keys before the transition cannot express
/// that a later unsigned frame inherits pre-transition authority.
/// - **Bearers** ([`BearerPolicy`]) — `mint_media_get_auth` /
/// `sign_blossom_upload_auth` sign a server-scoped Blossom header that
/// callers attach at LATER HTTP transmissions, up to ten minutes after
/// issuance. The bearer TTL is wider than a transition, so TTL is not a
/// substitute for invalidation.
///
/// Every durable capability is REGISTERED in this same egress registry and
/// STAMPED with the identity-persistence generation current at issuance
/// (issuance itself runs under a bounded lease — the signing that derives the
/// capability is an ordinary leased operation). Every transmission over it
/// validates, immediately before the irreversible boundary (frame send /
/// header attach), BOTH current egress admission AND
/// `capability_generation == current identity-persistence generation`
/// ([`OwnerIdentityCapability::admit_exercise`]) — a stale capability is
/// refused with zero bytes sent. The type is the only carrier of that stamped
/// generation, so no exercise site can skip the check (there is no raw handle
/// to exercise).
///
/// The registry additionally holds each capability's REVOCATION HANDLE (the
/// session cancellation token; the bearer's registry id for invalidation), so
/// the C5 coordinator barrier only *invokes* what C2 already registered — it
/// never retrofits the registry schema. Registering the teardown authority is
/// substrate (C2); invoking it at the transition barrier is C5.
#[derive(Debug)]
#[must_use = "a durable owner-identity capability must be validated \
(admit_exercise) immediately before every transmission over it"]
pub struct OwnerIdentityCapability<P: CapabilityPolicy> {
/// Registry id — the key under which this capability's revocation handle
/// lives, so the C5 barrier can invalidate it by generation.
id: u64,
/// The identity-persistence generation current when this capability was
/// issued. Exercise is refused once the registry generation advances past
/// it.
generation: u64,
_policy: std::marker::PhantomData<P>,
}
/// A policy distinguishing the durable capability kinds. Zero-sized markers —
/// the shared behavior (registration, generation-stamp, exercise validation,
/// revocation-handle storage) lives on [`OwnerIdentityCapability`]; the policy
/// only names the kind so the constructor inventory and the registry's
/// per-kind revocation are type-directed.
pub trait CapabilityPolicy: std::fmt::Debug + private::Sealed {
/// A human label for diagnostics and the closed-world inventory.
const KIND: &'static str;
}
/// Authenticated-connection authority (huddle audio socket, frontend relay
/// WS). The registered revocation handle is a [`CancellationToken`] whose
/// cancellation tears the connection down.
#[derive(Debug)]
pub struct SessionPolicy;
/// Pre-minted bearer authority (Blossom `t=get`/`t=upload` headers). The
/// registered revocation handle is the registry id; invalidation removes the
/// entry so a later attach fails admission.
#[derive(Debug)]
pub struct BearerPolicy;
impl CapabilityPolicy for SessionPolicy {
const KIND: &'static str = "session";
}
impl CapabilityPolicy for BearerPolicy {
const KIND: &'static str = "bearer";
}
mod private {
pub trait Sealed {}
impl Sealed for super::SessionPolicy {}
impl Sealed for super::BearerPolicy {}
}
/// The revocation authority for one registered durable capability, invoked by
/// the C5 coordinator barrier to tear down old-generation authority before the
/// journal write + durable dispatch.
enum RevocationHandle {
/// Cancel the connection (huddle socket / frontend WS teardown path).
Session(CancellationToken),
/// Bearer invalidation is by-entry: removing the registry entry is the
/// invalidation, so no side-effecting handle is needed. The variant exists
/// so the registry records the capability's kind for the per-kind barrier.
Bearer,
}
/// Registry of live durable capabilities, keyed by capability id. Guarded by
/// the same lock as the bounded-lease state so registration, exercise
/// validation, and the C5 barrier all linearize against generation bumps.
#[derive(Default)]
struct DurableRegistry {
/// Next capability id. Monotonic; ids are never reused.
next_id: u64,
/// Live capabilities: id → (issued generation, revocation handle).
entries: HashMap<u64, (u64, RevocationHandle)>,
}
static DURABLE: LazyLock<Mutex<DurableRegistry>> =
LazyLock::new(|| Mutex::new(DurableRegistry::default()));
fn lock_durable() -> std::sync::MutexGuard<'static, DurableRegistry> {
DURABLE.lock().unwrap_or_else(|p| p.into_inner())
}
impl<P: CapabilityPolicy> OwnerIdentityCapability<P> {
/// The identity-persistence generation this capability was issued under.
// Consumed by C5 (barrier introspection) and the C2 tests; remove allow
// when a production caller reads it.
#[allow(dead_code)]
pub fn generation(&self) -> u64 {
self.generation
}
/// Validate this capability immediately before an irreversible
/// transmission over it (frame send / header attach). Succeeds only when
/// egress admission is `Live` AND the stamped generation still equals the
/// current identity-persistence generation. A stale or drained capability
/// is refused so the caller sends zero bytes.
///
/// This is a cheap compare, not a re-authentication: the durable
/// capability already proved identity at issuance; exercise only confirms
/// that proof has not been superseded by a transition.
pub fn admit_exercise(&self) -> Result<(), String> {
let inner = lock_inner();
if inner.state != IdentityPersistenceState::Live {
return Err(format!(
"owner-identity {} capability cannot transmit: egress is {:?}",
P::KIND,
inner.state
));
}
let current = REGISTRY.generation.load(Ordering::Acquire);
if self.generation != current {
return Err(format!(
"owner-identity {} capability is stale (issued under generation \
{}, current {}); the identity transitioned and this authority \
was revoked",
P::KIND,
self.generation,
current
));
}
Ok(())
}
}
impl<P: CapabilityPolicy> Drop for OwnerIdentityCapability<P> {
fn drop(&mut self) {
// Dropping the capability handle deregisters it — a session whose task
// has ended or a bearer no longer attachable must not linger as a
// revocation target. Barrier invalidation (C5) also removes entries;
// the id is unique and never reused, so a double-remove is a no-op.
lock_durable().entries.remove(&self.id);
}
}
/// Issue a durable owner-identity session capability, registering its
/// cancellation token so the C5 barrier can tear the connection down.
///
/// Call this AFTER the authenticating sign/auth has completed under a bounded
/// lease (issuance is an ordinary leased operation, spec L4569–4570) and the
/// connection is established. The returned capability stamps the current
/// generation; the connection's send task validates it via
/// [`OwnerIdentityCapability::admit_exercise`] before each frame.
pub fn register_owner_session(cancel: CancellationToken) -> OwnerIdentityCapability<SessionPolicy> {
let generation = REGISTRY.generation.load(Ordering::Acquire);
let mut durable = lock_durable();
let id = durable.next_id;
durable.next_id += 1;
durable
.entries
.insert(id, (generation, RevocationHandle::Session(cancel)));
OwnerIdentityCapability {
id,
generation,
_policy: std::marker::PhantomData,
}
}
/// Issue a durable owner-identity bearer capability, registering it so the C5
/// barrier can invalidate it by generation.
///
/// Call this immediately after minting the bearer header under a bounded lease.
/// The returned capability stamps the current generation; every attach site
/// validates it via [`OwnerIdentityCapability::admit_exercise`] before the HTTP
/// dispatch.
pub fn register_owner_bearer() -> OwnerIdentityCapability<BearerPolicy> {
let generation = REGISTRY.generation.load(Ordering::Acquire);
let mut durable = lock_durable();
let id = durable.next_id;
durable.next_id += 1;
durable
.entries
.insert(id, (generation, RevocationHandle::Bearer));
OwnerIdentityCapability {
id,
generation,
_policy: std::marker::PhantomData,
}
}
/// Revoke every durable capability issued under a generation older than
/// `winning_generation`: cancel old sessions (invoking each registered
/// [`CancellationToken`]) and drop old bearers (removing their entries so a
/// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the
/// number of capabilities revoked.
///
/// The C5 coordinator barrier calls this after
/// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write +
/// durable B dispatch, so no old-generation session or bearer can transmit
/// across the durable boundary. It only *invokes* the handles C2 registered.
// Consumed by C5 (P25/P28 coordinator barrier); remove allow when C5 lands.
#[allow(dead_code)]
pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize {
let mut durable = lock_durable();
let stale: Vec<u64> = durable
.entries
.iter()
.filter(|(_, (gen, _))| *gen < winning_generation)
.map(|(id, _)| *id)
.collect();
for id in &stale {
if let Some((_, RevocationHandle::Session(cancel))) = durable.entries.remove(id) {
cancel.cancel();
}
}
stale.len()
}
/// The number of live durable capabilities registered. Used by the
/// registration-completeness assertion (C2) to prove every issued
/// session/bearer is present with a revocation handle the C5 barrier can
/// invoke.
// Consumed by C5 and the C2 registration-completeness tests; remove allow when
// a production caller lands.
#[allow(dead_code)]
pub fn live_durable_capability_count() -> usize {
lock_durable().entries.len()
}
/// Durable owner-identity capabilities (sessions, bearers) — authority that
/// outlives the bounded lease that derived it. Split into a child module for
/// file-size discipline; it shares this module's one registry.
mod durable;
pub use durable::{
register_owner_bearer, register_owner_session, BearerPolicy, OwnerIdentityCapability,
SessionPolicy,
};
// The C5 coordinator barrier and its registration-completeness reader; no
// production caller until C5, so the re-exports are unused today. Consumed by
// C5 (P25/P28); remove allow when C5 lands.
#[allow(unused_imports)]
pub use durable::revoke_durable_capabilities_before;
/// Process-global mutex serializing tests that mutate the registry's
/// process-global state. Any test that admits a lease, drives a drain, or
@@ -739,8 +500,7 @@ pub(crate) fn reset_registry_for_test() {
inner.state = IdentityPersistenceState::Live;
inner.in_flight = 0;
drop(inner);
let mut durable = lock_durable();
durable.entries.clear();
durable::reset_for_test();
// next_id is monotonic and never resets, mirroring the generation: tests
// assert on presence/count and relative generation, never absolute ids.
}
@@ -1006,150 +766,4 @@ mod tests {
);
crate::relay_admission::reset_rate_limit_gate();
}
// -----------------------------------------------------------------------
// Durable capability substrate (C2 / P30-C1)
// -----------------------------------------------------------------------
#[test]
fn durable_capabilities_stamp_the_current_generation() {
let _g = guard();
let session = register_owner_session(CancellationToken::new());
let bearer = register_owner_bearer();
let current = current_identity_persistence_generation();
assert_eq!(session.generation(), current);
assert_eq!(bearer.generation(), current);
}
#[test]
fn durable_capability_exercise_admits_when_live_and_current() {
let _g = guard();
let session = register_owner_session(CancellationToken::new());
let bearer = register_owner_bearer();
assert!(session.admit_exercise().is_ok());
assert!(bearer.admit_exercise().is_ok());
}
// §7 revocation schedule (session): a session issued under A cannot
// transmit after the generation advances to B — the frame send is refused
// with zero bytes. Drives the registry generation directly (C1 drain-test
// pattern); no production transition driver exists until C5.
#[test]
fn stale_session_exercise_refuses_after_a_generation_bump() {
let _g = guard();
let session = register_owner_session(CancellationToken::new());
// A transition drains then resumes at the winning generation.
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
session.admit_exercise().is_err(),
"a session stamped under A must refuse to transmit after B wins"
);
}
// §7 revocation schedule (bearer): same stale-generation control for the
// pre-minted bearer attach path.
#[test]
fn stale_bearer_exercise_refuses_after_a_generation_bump() {
let _g = guard();
let bearer = register_owner_bearer();
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
bearer.admit_exercise().is_err(),
"a bearer minted under A must refuse to attach after B wins"
);
}
// Stale-capability ZERO-BYTES control (Paul's condition a): exercise
// validation fails BEFORE any transmission, for both durable kinds, under
// both a generation mismatch and the drain/latch state — so no site can
// send a byte on stale authority.
#[test]
fn durable_exercise_refuses_while_draining_and_while_latched() {
let _g = guard();
let session = register_owner_session(CancellationToken::new());
let bearer = register_owner_bearer();
begin_egress_drain().unwrap();
assert!(
session.admit_exercise().is_err(),
"draining refuses session"
);
assert!(bearer.admit_exercise().is_err(), "draining refuses bearer");
latch_identity_indeterminate();
assert!(session.admit_exercise().is_err(), "latch refuses session");
assert!(bearer.admit_exercise().is_err(), "latch refuses bearer");
}
// The C5 barrier cancels old-generation sessions and drops old bearers,
// and does NOT touch capabilities issued at the winning generation.
#[test]
fn barrier_revokes_old_generation_capabilities_only() {
let _g = guard();
let old_session_cancel = CancellationToken::new();
let old_session = register_owner_session(old_session_cancel.clone());
let _old_bearer = register_owner_bearer();
assert_eq!(live_durable_capability_count(), 2);
// Transition to B.
let winning = begin_egress_drain().unwrap();
resume_egress_live();
// A capability issued at the winning generation survives the barrier.
let new_session = register_owner_session(CancellationToken::new());
let revoked = revoke_durable_capabilities_before(winning);
assert_eq!(revoked, 2, "both old-generation capabilities revoked");
assert!(
old_session_cancel.is_cancelled(),
"the old session's registered token was invoked"
);
assert_eq!(
live_durable_capability_count(),
1,
"only the winning-generation capability remains"
);
assert!(
new_session.admit_exercise().is_ok(),
"the winning-generation session still transmits"
);
// old_session is now deregistered by the barrier; dropping it is a
// no-op remove.
drop(old_session);
}
// Registration-completeness assertion (Paul's condition b): every issued
// durable capability is present in the registry with a revocation handle
// the C5 barrier can invoke, and dropping the handle deregisters it so a
// dead session/bearer is never a stale revocation target.
#[test]
fn issued_capabilities_are_registered_and_deregister_on_drop() {
let _g = guard();
assert_eq!(live_durable_capability_count(), 0);
let session = register_owner_session(CancellationToken::new());
assert_eq!(live_durable_capability_count(), 1);
let bearer = register_owner_bearer();
assert_eq!(live_durable_capability_count(), 2);
drop(session);
assert_eq!(live_durable_capability_count(), 1, "session deregistered");
drop(bearer);
assert_eq!(live_durable_capability_count(), 0, "bearer deregistered");
}
// A no-transition control: with no generation bump, a registered
// capability keeps transmitting and the barrier revokes nothing.
#[test]
fn no_transition_leaves_durable_capabilities_intact() {
let _g = guard();
let session = register_owner_session(CancellationToken::new());
let current = current_identity_persistence_generation();
assert_eq!(
revoke_durable_capabilities_before(current),
0,
"nothing older than the current generation to revoke"
);
assert!(
session.admit_exercise().is_ok(),
"with no transition the session still transmits"
);
}
}

View File

@@ -626,380 +626,5 @@ pub async fn submit_signed_event_with_keys(
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::{
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
extract_retry_in_hint, parse_command_response, relay_http_base_url,
MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;
// ── extract_retry_in_hint ────────────────────────────────────────────────
#[test]
fn extracts_hint_from_429_body() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#),
Some(4)
);
}
#[test]
fn extracts_hint_when_no_json_wrapper() {
assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30));
}
#[test]
fn returns_none_when_no_hint_present() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#),
None
);
assert_eq!(extract_retry_in_hint(""), None);
}
#[test]
fn overlong_digit_string_returns_none() {
// A digit sequence that exceeds u64::MAX cannot be parsed; the function
// must return None (→ caller uses the default) rather than panicking.
assert_eq!(
extract_retry_in_hint("retry in 99999999999999999999999s"),
None
);
}
// ── relay_error_message: hint capping ────────────────────────────────────
//
// Verify that an oversized relay hint is capped in the returned message
// string, not just inside `activate_rate_limit()`. This guarantees every
// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
// receives the capped value rather than the raw untrusted relay value.
#[tokio::test]
async fn oversized_hint_is_capped_in_relay_error_message_string() {
use crate::relay_admission::MAX_HINT_SECONDS;
use std::io::{Read as _, Write as _};
// Use a std::net listener on a std::thread — the same pattern as the
// relay_admission loopback tests. This avoids two races that cause CI
// failures with tokio::net + into_std():
// 1. No request read: the client is still sending when the response
// arrives → hyper `UnexpectedMessage`/`Canceled` under load.
// 2. into_std() leaves the socket in nonblocking mode → write_all
// may return WouldBlock and silently drop the response.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300).
let oversized = 1_000_000u64;
let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#);
let body_len = body.len();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
// Read the request first so the client finishes sending before
// we write the response — mirrors relay_admission.rs pattern.
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}"
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
let client = reqwest::Client::new();
let response = client
.get(format!("http://{addr}/"))
.send()
.await
.expect("request must succeed");
let msg = super::relay_error_message(response).await;
// The message must embed the CAPPED hint, not the raw 1 000 000.
assert_eq!(
msg,
format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"),
"relay_error_message must embed the capped hint, not the raw untrusted value"
);
assert!(
!msg.contains(&oversized.to_string()),
"raw oversized hint must not appear in the message string"
);
}
// ── effective_agent_relay_url: legacy pin ignored ─────────────────────────
#[test]
fn stored_relay_pin_is_ignored() {
// Zero-touch cutover (#2122): a creation-era per-record relay pin is
// parsed and persisted but never consulted — the workspace relay wins.
assert_eq!(
effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn empty_relay_resolves_to_workspace() {
// A never-set record resolves to the active workspace relay at read-time,
// so a stale stored default can never make it load-bearing.
assert_eq!(
effective_agent_relay_url("", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn whitespace_only_relay_resolves_to_workspace() {
// Whitespace-only behaves identically — no value survives.
assert_eq!(
effective_agent_relay_url(" ", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
// ── relay_http_base_url scheme conversion ────────────────────────────────
#[test]
fn loopback_ws_localhost_preserves_authority() {
// Tenant host-binding keys off the HTTP Host/authority. The desktop must
// not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a
// different unmapped community than the WebSocket URL.
assert_eq!(
relay_http_base_url("ws://localhost:3000"),
"http://localhost:3000"
);
}
#[test]
fn loopback_trailing_slash_removed_authority_preserved() {
assert_eq!(
relay_http_base_url("ws://localhost:3000/"),
"http://localhost:3000"
);
}
#[test]
fn remote_wss_host_unchanged() {
assert_eq!(
relay_http_base_url("wss://relay.example.com"),
"https://relay.example.com"
);
}
#[test]
fn loopback_ipv4_literal_unchanged() {
assert_eq!(
relay_http_base_url("ws://127.0.0.1:3000"),
"http://127.0.0.1:3000"
);
}
#[test]
fn localhost_substring_host_unchanged() {
assert_eq!(
relay_http_base_url("ws://localhost.evil.com:3000"),
"http://localhost.evil.com:3000"
);
}
#[test]
fn loopback_wss_localhost_preserves_authority() {
assert_eq!(
relay_http_base_url("wss://localhost:3000"),
"https://localhost:3000"
);
}
// ── classify_intercepted_response ────────────────────────────────────────
#[test]
fn intercepted_cloudflare_host_returns_some() {
let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html");
assert!(result.is_some());
let msg = result.unwrap();
assert!(
msg.starts_with("relay unreachable:"),
"should have unreachable prefix"
);
assert!(msg.contains("Cloudflare"), "should mention Cloudflare");
}
#[test]
fn intercepted_cloudflare_apex_host_returns_some() {
// The apex domain itself should also match.
let result = classify_intercepted_response("cloudflareaccess.com", "application/json");
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.starts_with("relay unreachable:"));
assert!(msg.contains("Cloudflare"));
}
#[test]
fn intercepted_non_cloudflare_html_returns_some() {
let result =
classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8");
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.starts_with("relay unreachable:"));
}
#[test]
fn normal_relay_json_returns_none() {
let result = classify_intercepted_response("relay.myapp.example.com", "application/json");
assert!(result.is_none());
}
#[test]
fn content_type_case_insensitive() {
// Uppercase content-type must still be detected.
let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML");
assert!(result.is_some());
assert!(result.unwrap().starts_with("relay unreachable:"));
}
#[test]
fn evil_suffix_does_not_match_cloudflare() {
// A host whose suffix happens to contain the Cloudflare string but is
// not actually a subdomain must NOT match.
let result = classify_intercepted_response(
"notcloudflareaccess.com.evil.example",
"application/json",
);
assert!(
result.is_none(),
"false suffix match should not trigger Cloudflare branch"
);
}
// classify_request_error requires a real reqwest::Error (not publicly
// constructable) — tested indirectly through integration; skipped here.
// ── parse_json_response malformed-body contract ──────────────────────────
#[test]
fn malformed_response_message_stays_off_unreachable_bucket() {
// A reached-but-malformed 2xx body is not a connectivity failure. If this
// message ever regains the "relay unreachable:" prefix, the frontend
// classifier would misroute it as unreachable — pin that it never does.
assert!(
!MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"),
"malformed-response message must not match the unreachable prefix"
);
}
// ── parse_command_response ───────────────────────────────────────────────
#[derive(Debug, Deserialize, PartialEq)]
struct ChannelCreated {
channel_id: String,
}
#[test]
fn parse_command_response_decodes_typed_payload() {
let msg = r#"response:{"channel_id":"abc123"}"#;
let parsed: ChannelCreated = parse_command_response(msg).expect("should parse");
assert_eq!(
parsed,
ChannelCreated {
channel_id: "abc123".to_string()
}
);
}
#[test]
fn parse_command_response_accepts_raw_json_fallback() {
// Backward-compat: relays that emit raw JSON (no prefix) still work.
let msg = r#"{"channel_id":"abc"}"#;
let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse");
assert_eq!(
parsed,
ChannelCreated {
channel_id: "abc".to_string()
}
);
}
#[test]
fn parse_command_response_rejects_invalid_prefixed_json() {
let msg = "response:not-json";
let result: Result<ChannelCreated, _> = parse_command_response(msg);
assert!(result.is_err());
assert!(result.unwrap_err().contains("response parse failed"));
}
#[test]
fn parse_command_response_rejects_garbage() {
let msg = "totally not json or response";
let result: Result<ChannelCreated, _> = parse_command_response(msg);
assert!(result.is_err());
}
// ── build_profile_event ──────────────────────────────────────────────────
/// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key
/// and addressed to `agent_keys`.
///
/// Uses `nostr_compat` (nostr 0.36) for the owner keys because
/// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types.
/// The agent pubkey is bridged via hex encoding.
fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
let owner_keys = nostr::Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
let agent_compat_pubkey =
nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse");
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "")
.expect("compute_auth_tag should not fail with distinct keys")
}
#[test]
fn profile_event_with_valid_auth_tag() {
let agent_keys = nostr::Keys::generate();
let tag_json = make_valid_auth_tag(&agent_keys);
let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json))
.expect("should succeed with a valid auth tag");
// Exactly one "auth" tag must be present.
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag");
// Must be a kind:0 (Metadata) event.
assert_eq!(event.kind, nostr::Kind::Metadata);
}
#[test]
fn profile_event_without_auth_tag() {
let agent_keys = nostr::Keys::generate();
let event = build_profile_event(&agent_keys, "TestBot", None, None)
.expect("should succeed without an auth tag");
// No "auth" tags should be present.
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert_eq!(auth_tags.len(), 0, "expected no auth tags");
assert_eq!(event.kind, nostr::Kind::Metadata);
}
#[test]
fn profile_event_rejects_invalid_auth_tag() {
let agent_keys = nostr::Keys::generate();
// Structurally valid JSON array but with a bogus signature — verification must fail.
let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json));
assert!(result.is_err(), "should reject an invalid auth tag");
assert!(
result.unwrap_err().contains("verification failed"),
"error message should mention verification failure"
);
}
}
#[path = "relay_tests.rs"]
mod tests;

View File

@@ -0,0 +1,375 @@
//! Unit tests for the relay module. Split from `relay.rs` for file-size
//! discipline; wired via `#[path]` from that file's `mod tests`.
use super::{
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;
// ── extract_retry_in_hint ────────────────────────────────────────────────
#[test]
fn extracts_hint_from_429_body() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#),
Some(4)
);
}
#[test]
fn extracts_hint_when_no_json_wrapper() {
assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30));
}
#[test]
fn returns_none_when_no_hint_present() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#),
None
);
assert_eq!(extract_retry_in_hint(""), None);
}
#[test]
fn overlong_digit_string_returns_none() {
// A digit sequence that exceeds u64::MAX cannot be parsed; the function
// must return None (→ caller uses the default) rather than panicking.
assert_eq!(
extract_retry_in_hint("retry in 99999999999999999999999s"),
None
);
}
// ── relay_error_message: hint capping ────────────────────────────────────
//
// Verify that an oversized relay hint is capped in the returned message
// string, not just inside `activate_rate_limit()`. This guarantees every
// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
// receives the capped value rather than the raw untrusted relay value.
#[tokio::test]
async fn oversized_hint_is_capped_in_relay_error_message_string() {
use crate::relay_admission::MAX_HINT_SECONDS;
use std::io::{Read as _, Write as _};
// Use a std::net listener on a std::thread — the same pattern as the
// relay_admission loopback tests. This avoids two races that cause CI
// failures with tokio::net + into_std():
// 1. No request read: the client is still sending when the response
// arrives → hyper `UnexpectedMessage`/`Canceled` under load.
// 2. into_std() leaves the socket in nonblocking mode → write_all
// may return WouldBlock and silently drop the response.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300).
let oversized = 1_000_000u64;
let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#);
let body_len = body.len();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
// Read the request first so the client finishes sending before
// we write the response — mirrors relay_admission.rs pattern.
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}"
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
let client = reqwest::Client::new();
let response = client
.get(format!("http://{addr}/"))
.send()
.await
.expect("request must succeed");
let msg = super::relay_error_message(response).await;
// The message must embed the CAPPED hint, not the raw 1 000 000.
assert_eq!(
msg,
format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"),
"relay_error_message must embed the capped hint, not the raw untrusted value"
);
assert!(
!msg.contains(&oversized.to_string()),
"raw oversized hint must not appear in the message string"
);
}
// ── effective_agent_relay_url: legacy pin ignored ─────────────────────────
#[test]
fn stored_relay_pin_is_ignored() {
// Zero-touch cutover (#2122): a creation-era per-record relay pin is
// parsed and persisted but never consulted — the workspace relay wins.
assert_eq!(
effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn empty_relay_resolves_to_workspace() {
// A never-set record resolves to the active workspace relay at read-time,
// so a stale stored default can never make it load-bearing.
assert_eq!(
effective_agent_relay_url("", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn whitespace_only_relay_resolves_to_workspace() {
// Whitespace-only behaves identically — no value survives.
assert_eq!(
effective_agent_relay_url(" ", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
// ── relay_http_base_url scheme conversion ────────────────────────────────
#[test]
fn loopback_ws_localhost_preserves_authority() {
// Tenant host-binding keys off the HTTP Host/authority. The desktop must
// not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a
// different unmapped community than the WebSocket URL.
assert_eq!(
relay_http_base_url("ws://localhost:3000"),
"http://localhost:3000"
);
}
#[test]
fn loopback_trailing_slash_removed_authority_preserved() {
assert_eq!(
relay_http_base_url("ws://localhost:3000/"),
"http://localhost:3000"
);
}
#[test]
fn remote_wss_host_unchanged() {
assert_eq!(
relay_http_base_url("wss://relay.example.com"),
"https://relay.example.com"
);
}
#[test]
fn loopback_ipv4_literal_unchanged() {
assert_eq!(
relay_http_base_url("ws://127.0.0.1:3000"),
"http://127.0.0.1:3000"
);
}
#[test]
fn localhost_substring_host_unchanged() {
assert_eq!(
relay_http_base_url("ws://localhost.evil.com:3000"),
"http://localhost.evil.com:3000"
);
}
#[test]
fn loopback_wss_localhost_preserves_authority() {
assert_eq!(
relay_http_base_url("wss://localhost:3000"),
"https://localhost:3000"
);
}
// ── classify_intercepted_response ────────────────────────────────────────
#[test]
fn intercepted_cloudflare_host_returns_some() {
let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html");
assert!(result.is_some());
let msg = result.unwrap();
assert!(
msg.starts_with("relay unreachable:"),
"should have unreachable prefix"
);
assert!(msg.contains("Cloudflare"), "should mention Cloudflare");
}
#[test]
fn intercepted_cloudflare_apex_host_returns_some() {
// The apex domain itself should also match.
let result = classify_intercepted_response("cloudflareaccess.com", "application/json");
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.starts_with("relay unreachable:"));
assert!(msg.contains("Cloudflare"));
}
#[test]
fn intercepted_non_cloudflare_html_returns_some() {
let result =
classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8");
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.starts_with("relay unreachable:"));
}
#[test]
fn normal_relay_json_returns_none() {
let result = classify_intercepted_response("relay.myapp.example.com", "application/json");
assert!(result.is_none());
}
#[test]
fn content_type_case_insensitive() {
// Uppercase content-type must still be detected.
let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML");
assert!(result.is_some());
assert!(result.unwrap().starts_with("relay unreachable:"));
}
#[test]
fn evil_suffix_does_not_match_cloudflare() {
// A host whose suffix happens to contain the Cloudflare string but is
// not actually a subdomain must NOT match.
let result =
classify_intercepted_response("notcloudflareaccess.com.evil.example", "application/json");
assert!(
result.is_none(),
"false suffix match should not trigger Cloudflare branch"
);
}
// classify_request_error requires a real reqwest::Error (not publicly
// constructable) — tested indirectly through integration; skipped here.
// ── parse_json_response malformed-body contract ──────────────────────────
#[test]
fn malformed_response_message_stays_off_unreachable_bucket() {
// A reached-but-malformed 2xx body is not a connectivity failure. If this
// message ever regains the "relay unreachable:" prefix, the frontend
// classifier would misroute it as unreachable — pin that it never does.
assert!(
!MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"),
"malformed-response message must not match the unreachable prefix"
);
}
// ── parse_command_response ───────────────────────────────────────────────
#[derive(Debug, Deserialize, PartialEq)]
struct ChannelCreated {
channel_id: String,
}
#[test]
fn parse_command_response_decodes_typed_payload() {
let msg = r#"response:{"channel_id":"abc123"}"#;
let parsed: ChannelCreated = parse_command_response(msg).expect("should parse");
assert_eq!(
parsed,
ChannelCreated {
channel_id: "abc123".to_string()
}
);
}
#[test]
fn parse_command_response_accepts_raw_json_fallback() {
// Backward-compat: relays that emit raw JSON (no prefix) still work.
let msg = r#"{"channel_id":"abc"}"#;
let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse");
assert_eq!(
parsed,
ChannelCreated {
channel_id: "abc".to_string()
}
);
}
#[test]
fn parse_command_response_rejects_invalid_prefixed_json() {
let msg = "response:not-json";
let result: Result<ChannelCreated, _> = parse_command_response(msg);
assert!(result.is_err());
assert!(result.unwrap_err().contains("response parse failed"));
}
#[test]
fn parse_command_response_rejects_garbage() {
let msg = "totally not json or response";
let result: Result<ChannelCreated, _> = parse_command_response(msg);
assert!(result.is_err());
}
// ── build_profile_event ──────────────────────────────────────────────────
/// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key
/// and addressed to `agent_keys`.
///
/// Uses `nostr_compat` (nostr 0.36) for the owner keys because
/// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types.
/// The agent pubkey is bridged via hex encoding.
fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
let owner_keys = nostr::Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
let agent_compat_pubkey =
nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse");
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "")
.expect("compute_auth_tag should not fail with distinct keys")
}
#[test]
fn profile_event_with_valid_auth_tag() {
let agent_keys = nostr::Keys::generate();
let tag_json = make_valid_auth_tag(&agent_keys);
let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json))
.expect("should succeed with a valid auth tag");
// Exactly one "auth" tag must be present.
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag");
// Must be a kind:0 (Metadata) event.
assert_eq!(event.kind, nostr::Kind::Metadata);
}
#[test]
fn profile_event_without_auth_tag() {
let agent_keys = nostr::Keys::generate();
let event = build_profile_event(&agent_keys, "TestBot", None, None)
.expect("should succeed without an auth tag");
// No "auth" tags should be present.
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert_eq!(auth_tags.len(), 0, "expected no auth tags");
assert_eq!(event.kind, nostr::Kind::Metadata);
}
#[test]
fn profile_event_rejects_invalid_auth_tag() {
let agent_keys = nostr::Keys::generate();
// Structurally valid JSON array but with a bogus signature — verification must fail.
let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json));
assert!(result.is_err(), "should reject an invalid auth tag");
assert!(
result.unwrap_err().contains("verification failed"),
"error message should mention verification failure"
);
}