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`.
|
||||
///
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user