feat(maestro): registry HTTP API, cookie auth, JWT issuance, events WS
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:
2026-05-24 17:08:41 +00:00
co-authored by Claude Opus 4.7
parent 499cffe397
commit 18bd385a2a
17 changed files with 2058 additions and 72 deletions
+123
View File
@@ -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 {