From de70a52e5b9dbe9f0d48306cd9290d97ca7fb516 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Tue, 26 May 2026 17:43:10 +0000 Subject: [PATCH] feat(maestro): add AttachCandidates protocol types (NAP-242) Add the wire types for broker-mediated attach: PerimeterCandidate (T2 hint), RelayCandidate (T3 marker, shape TBD via NAP-238), and AttachCandidates as the registry's reply to ResolveAttach. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/maestro-protocol/src/lib.rs | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index d5339db..57f47a1 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -155,12 +155,58 @@ pub struct DaemonInfo { } /// Response body for `GET /api/sessions/:id/connect-info`. +/// +/// Superseded by the broker-mediated `ResolveAttach` / [`AttachCandidates`] +/// flow added for the connection-orchestration epic (NAP-236). Retained as a +/// compat path while clients migrate; new code should prefer the broker model. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectInfo { pub daemon_url: String, pub jwt: String, } +// --------------------------------------------------------------------------- +// Broker-mediated attach (NAP-242) +// +// Wire types for the multi-transport attach flow defined in +// `docs/superpowers/specs/2026-05-26-connection-orchestration.md`. The home +// daemon asks the registry to `ResolveAttach`; the registry replies with +// `AttachCandidates` describing the transports the home daemon may try (T1 +// direct WSS, T2 direct WS over shared perimeter, T3 registry-mediated relay) +// plus a pair-scoped JWT. For T3, the registry signals the target daemon with +// `RelaySetup` and only allocates relay capacity after a `RelayAck`. +// --------------------------------------------------------------------------- + +/// T2 transport hint: target daemon's reachable address on a perimeter the +/// home daemon may share. The home daemon dials `ws:///...` only +/// if its own `perimeter_id` matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PerimeterCandidate { + pub perimeter_id: String, + pub private_addr: String, +} + +/// T3 transport marker. The concrete shape (relay endpoint, ticket, etc.) +/// depends on the relay backend selected by NAP-238 (Iroh vs libp2p +/// circuit-v2), so this stays a `#[non_exhaustive]` placeholder for now. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct RelayCandidate {} + +/// Registry's reply to `ResolveAttach`. Each `Option` describes one viable +/// transport; the home daemon tries them in T1 → T2 → T3 order. `jwt` is the +/// pair-scoped credential ([`PairAttachClaims`]) target daemons validate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttachCandidates { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_wss: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perimeter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay: Option, + pub jwt: String, +} + /// Messages broadcast by the registry to web clients over `WS /api/events`. /// /// A subscriber receives a single `Snapshot` immediately on connect, then a @@ -495,6 +541,50 @@ mod tests { assert_eq!(json["type"], "DaemonRemoved"); } + #[test] + fn perimeter_candidate_round_trips() { + let p = PerimeterCandidate { + perimeter_id: "wg-naps62".into(), + private_addr: "10.0.0.7:7070".into(), + }; + assert_eq!(p, round_trip(&p)); + } + + #[test] + fn relay_candidate_round_trips_as_empty_object() { + let r = RelayCandidate::default(); + assert_eq!(r, round_trip(&r)); + assert_eq!(serde_json::to_value(&r).unwrap(), serde_json::json!({})); + } + + #[test] + fn attach_candidates_full_and_minimal_round_trip() { + let full = AttachCandidates { + direct_wss: Some("wss://konishi.naps62.maestro.example/".into()), + perimeter: Some(PerimeterCandidate { + perimeter_id: "wg-naps62".into(), + private_addr: "10.0.0.7:7070".into(), + }), + relay: Some(RelayCandidate::default()), + jwt: "eyJ...".into(), + }; + assert_eq!(full, round_trip(&full)); + + let minimal = AttachCandidates { + direct_wss: None, + perimeter: None, + relay: None, + jwt: "eyJ...".into(), + }; + assert_eq!(minimal, round_trip(&minimal)); + + // Optional transport fields are dropped from the wire when absent — + // keeps minimal payloads tight and avoids accidentally implying a + // transport is available with a `null` value. + let v = serde_json::to_value(&minimal).unwrap(); + assert_eq!(v, serde_json::json!({ "jwt": "eyJ..." })); + } + #[test] fn jwt_claims_round_trip_and_iat_optional() { let c = JwtClaims {