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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 17:43:10 +00:00
co-authored by Claude Opus 4.7
parent b27ba06391
commit de70a52e5b
+90
View File
@@ -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://<private_addr>/...` 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perimeter: Option<PerimeterCandidate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay: Option<RelayCandidate>,
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 {