feat(maestro): registry HTTP API, cookie auth, JWT issuance, events WS
ci / rust (push) Successful in 48s
ci / rust (push) Successful in 48s
Adds the client-facing surface on the registry (NAP-215). Endpoints:
- GET /api/daemons — list connected daemons (with daemon_url)
- GET /api/sessions — flat session catalog
- POST /api/sessions — forwards CreateSession to chosen daemon, 504 on
10s daemon timeout
- DELETE /api/sessions/:id — forwards KillSession
- GET /api/sessions/:id/connect-info — returns {daemon_url, jwt} with a
5min HS256-signed attach JWT
- WS /api/events — snapshot + delta broadcast of catalog and presence
- GET / placeholder served until NAP-218 web bundle lands
Cookie auth middleware mints a long-lived httpOnly JWT cookie on first
request and reissues silently on missing/tampered cookies. Per spec the
perimeter is the operator's network — the cookie is a second factor.
Protocol additions: daemon_url on Hello, DaemonInfo, ConnectInfo,
RegistryEvent. Daemon config + RegistryClient now carry daemon_url.
Registry state now exposes an event broadcast channel (subscribe gives
a snapshot + tokio::sync::broadcast::Receiver) and helper lookups
daemon_for_session / daemon_url.
Tests: 11 reqwest-driven HTTP integration tests cover every endpoint,
the cookie flow, 504 timeout path, worktree forwarding, and events WS
propagation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,11 @@ pub enum DaemonOutbound {
|
||||
hostname: String,
|
||||
version: String,
|
||||
token: String,
|
||||
/// Reachable URL of this daemon's per-session WS endpoint, as the
|
||||
/// operator wants web clients to dial it. The registry hands this back
|
||||
/// in `GET /api/sessions/:id/connect-info` so the client can connect
|
||||
/// directly. Example: `ws://10.0.0.1:8081`.
|
||||
daemon_url: String,
|
||||
},
|
||||
SessionsSnapshot {
|
||||
sessions: Vec<Session>,
|
||||
@@ -135,6 +140,62 @@ pub enum DaemonInbound {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client ↔ Registry API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Public, read-only view of a connected daemon. Returned by `GET /api/daemons`
|
||||
/// and included in `RegistryEvent::Snapshot` / `RegistryEvent::DaemonAdded`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DaemonInfo {
|
||||
pub daemon_id: String,
|
||||
pub hostname: String,
|
||||
pub version: String,
|
||||
pub daemon_url: String,
|
||||
}
|
||||
|
||||
/// Response body for `GET /api/sessions/:id/connect-info`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConnectInfo {
|
||||
pub daemon_url: String,
|
||||
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
|
||||
/// stream of deltas as daemons connect/disconnect and their session sets
|
||||
/// change. Skipped events (broadcast channel lag) surface as `Lagged`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RegistryEvent {
|
||||
Snapshot {
|
||||
daemons: Vec<DaemonInfo>,
|
||||
sessions: Vec<Session>,
|
||||
},
|
||||
DaemonAdded {
|
||||
daemon: DaemonInfo,
|
||||
},
|
||||
DaemonRemoved {
|
||||
daemon_id: String,
|
||||
},
|
||||
SessionAdded {
|
||||
session: Session,
|
||||
},
|
||||
SessionRemoved {
|
||||
daemon_id: String,
|
||||
id: Uuid,
|
||||
},
|
||||
SessionUpdated {
|
||||
session: Session,
|
||||
},
|
||||
/// Server-issued notification that the broadcast queue dropped frames.
|
||||
/// Clients should re-fetch a full snapshot.
|
||||
Lagged {
|
||||
skipped: u64,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -268,6 +329,7 @@ mod tests {
|
||||
hostname: "naps-laptop".into(),
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
token: "shhh".into(),
|
||||
daemon_url: "ws://10.0.0.1:8081".into(),
|
||||
};
|
||||
let v = serde_json::to_value(&hello).unwrap();
|
||||
assert_eq!(v["type"], "Hello");
|
||||
@@ -372,6 +434,67 @@ mod tests {
|
||||
assert!(err.to_string().contains("Bogus"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_info_round_trips() {
|
||||
let d = DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "naps-laptop".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.1:8081".into(),
|
||||
};
|
||||
assert_eq!(d, round_trip(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_info_round_trips() {
|
||||
let c = ConnectInfo {
|
||||
daemon_url: "ws://10.0.0.1:8081".into(),
|
||||
jwt: "eyJ...".into(),
|
||||
};
|
||||
assert_eq!(c, round_trip(&c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_event_variants_round_trip_and_tag() {
|
||||
let daemon = DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "naps-laptop".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.1:8081".into(),
|
||||
};
|
||||
let session = sample_session();
|
||||
let variants = vec![
|
||||
RegistryEvent::Snapshot {
|
||||
daemons: vec![daemon.clone()],
|
||||
sessions: vec![session.clone()],
|
||||
},
|
||||
RegistryEvent::DaemonAdded {
|
||||
daemon: daemon.clone(),
|
||||
},
|
||||
RegistryEvent::DaemonRemoved {
|
||||
daemon_id: "laptop".into(),
|
||||
},
|
||||
RegistryEvent::SessionAdded {
|
||||
session: session.clone(),
|
||||
},
|
||||
RegistryEvent::SessionRemoved {
|
||||
daemon_id: "laptop".into(),
|
||||
id: session.id,
|
||||
},
|
||||
RegistryEvent::SessionUpdated { session },
|
||||
RegistryEvent::Lagged { skipped: 12 },
|
||||
];
|
||||
for v in variants {
|
||||
assert_eq!(v, round_trip(&v));
|
||||
}
|
||||
|
||||
let json = serde_json::to_value(RegistryEvent::DaemonRemoved {
|
||||
daemon_id: "x".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(json["type"], "DaemonRemoved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_claims_round_trip_and_iat_optional() {
|
||||
let c = JwtClaims {
|
||||
|
||||
Reference in New Issue
Block a user