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:
@@ -155,12 +155,58 @@ pub struct DaemonInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Response body for `GET /api/sessions/:id/connect-info`.
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ConnectInfo {
|
pub struct ConnectInfo {
|
||||||
pub daemon_url: String,
|
pub daemon_url: String,
|
||||||
pub jwt: 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`.
|
/// Messages broadcast by the registry to web clients over `WS /api/events`.
|
||||||
///
|
///
|
||||||
/// A subscriber receives a single `Snapshot` immediately on connect, then a
|
/// A subscriber receives a single `Snapshot` immediately on connect, then a
|
||||||
@@ -495,6 +541,50 @@ mod tests {
|
|||||||
assert_eq!(json["type"], "DaemonRemoved");
|
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]
|
#[test]
|
||||||
fn jwt_claims_round_trip_and_iat_optional() {
|
fn jwt_claims_round_trip_and_iat_optional() {
|
||||||
let c = JwtClaims {
|
let c = JwtClaims {
|
||||||
|
|||||||
Reference in New Issue
Block a user