Skip to content

Commit f300426

Browse files
willwashburnclaude
andauthored
feat(broker): add POST /api/observer-token to mint scoped read-only tokens (#1223)
* feat(broker): add POST /api/observer-token to mint scoped read-only tokens Pear's "Join as observer" feature currently builds shareable links by embedding the full rk_live_... Relaycast workspace API key, granting whoever holds the link full read/write/spawn access to the whole workspace. This adds the broker-side capability Pear's fix needs: a local HTTP endpoint that mints a scoped, read-only ot_live_... observer token via the Relaycast SDK's create_observer_token, instead of handing out the raw workspace credential. - ListenApiRequest::CreateObserverToken (listen_api.rs) resolves the target workspace the same way /api/send does (workspaceId / workspaceAlias / sole-attached / default), then mints a token scoped to stream/messages/threads/dms/channels/activity/agents:read. - New RelaycastHttpClient::create_observer_token wraps the SDK call (relaycast/ws.rs), following the register_action pattern. - Extracted the shared workspace-resolution logic used by /api/send and /api/observer-token into resolve_workspace() (runtime/api.rs), with unit tests covering explicit id/alias, case-insensitive alias matching, sole-workspace and default-workspace fallback, ambiguous- workspace, and not-found paths, plus a test asserting the minted scope set. - Route is protected by the existing listen_api_auth_middleware, same as every other /api/* route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(broker): harden observer-token endpoint per review feedback Address three review findings on the new POST /api/observer-token endpoint (PR #1223), which mints scoped read-only Relaycast observer tokens so callers stop embedding the full rk_live_... workspace key: - Parse the request body as raw bytes instead of via axum's Json / Option<Json<_>> extractor. Axum still attempts to parse the body when Content-Type: application/json is present, even if empty, and Option<Json<_>> only short-circuits to None when the header itself is missing -- so a dashboard client that sets JSON headers unconditionally on a request with every field optional would get rejected before the handler ran. Empty/whitespace-only bodies are now treated as {}. - Reject requests with 400 when workspaceId/workspace_id, workspaceAlias/workspace_alias, or name are present but not strings, instead of silently treating them as absent. For a credential-minting endpoint, silently falling back to the default/sole workspace on a malformed selector is a real risk. - Bound the create_observer_token SDK call in the broker runtime task with the same timeout() used for Send's Relaycast publish call, so a hung SDK call can't block the runtime task indefinitely (the HTTP layer's own 504 timeout only frees the caller, not this task). Adds regression tests for the empty-body case, non-string field rejection, and malformed JSON bodies. * fix(broker): exact scope assertion + dedicated observer-token timeout Address two cubic-dev-ai re-review findings on PR #1223: - default_observer_token_scopes_are_read_only_and_exclude_unneeded_scopes only asserted inclusion of 7 scopes and exclusion of 5 others, so an accidentally-added extra scope (including a write scope) would still pass. Now asserts the exact set via HashSet equality plus a length check, which fails on any scope drift for this credential-minting endpoint's minimal grant. - create_observer_token's runtime timeout reused http_api_relaycast_send_timeout() (and its AGENT_RELAY_HTTP_API_RELAYCAST_SEND_TIMEOUT_MS env var), which is named/scoped for the /api/send publish path. Introduced a dedicated http_api_observer_token_timeout() (AGENT_RELAY_HTTP_API_OBSERVER_TOKEN_TIMEOUT_MS, same 20s default) so tuning send-path latency can't unintentionally affect token minting. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 20b88df commit f300426

6 files changed

Lines changed: 660 additions & 45 deletions

File tree

crates/broker/src/listen_api.rs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ pub enum ListenApiRequest {
191191
metadata: Option<Value>,
192192
reply: tokio::sync::oneshot::Sender<Result<Value, AgentResultRouteError>>,
193193
},
194+
/// `POST /api/observer-token` — mint a scoped, read-only Relaycast
195+
/// observer token (`ot_live_...`) for the resolved workspace. Used by
196+
/// local dashboard clients (e.g. Pear's "Join as observer" link) so they
197+
/// stop embedding the full `rk_live_...` workspace key, which grants
198+
/// full read/write/spawn access, in shareable links.
199+
CreateObserverToken {
200+
workspace_id: Option<WorkspaceId>,
201+
workspace_alias: Option<WorkspaceAlias>,
202+
name: Option<String>,
203+
reply: tokio::sync::oneshot::Sender<Result<Value, String>>,
204+
},
194205
FleetSidecarConnect {
195206
outbound: mpsc::Sender<ProtocolEnvelope<Value>>,
196207
reply: tokio::sync::oneshot::Sender<Result<Value, String>>,
@@ -412,6 +423,10 @@ fn listen_api_router_with_auth(
412423
routing::post(listen_api_interrupt),
413424
)
414425
.route("/api/send", routing::post(listen_api_send))
426+
.route(
427+
"/api/observer-token",
428+
routing::post(listen_api_create_observer_token),
429+
)
415430
.route("/api/input/{name}", routing::post(listen_api_send_input))
416431
.route(
417432
"/api/input/{name}/stream",
@@ -1285,6 +1300,124 @@ async fn listen_api_send(
12851300
}
12861301
}
12871302

1303+
/// `POST /api/observer-token` — mint a scoped, read-only observer token for
1304+
/// the resolved workspace so local dashboard clients (e.g. Pear's "Join as
1305+
/// observer" link) never have to embed the full workspace API key.
1306+
async fn listen_api_create_observer_token(
1307+
axum::extract::State(state): axum::extract::State<ListenApiState>,
1308+
body: axum::body::Bytes,
1309+
) -> (axum::http::StatusCode, axum::Json<Value>) {
1310+
// Parse raw bytes instead of using the `Json`/`Option<Json<_>>` extractor:
1311+
// dashboard clients may send `Content-Type: application/json` with an
1312+
// empty body (every field here is optional), and axum still attempts to
1313+
// parse that body and rejects the request before this handler runs.
1314+
let body: Value = if body.is_empty() {
1315+
Value::Null
1316+
} else {
1317+
match serde_json::from_slice::<Value>(&body) {
1318+
Ok(value) => value,
1319+
Err(err) => {
1320+
return (
1321+
axum::http::StatusCode::BAD_REQUEST,
1322+
axum::Json(json!({
1323+
"success": false,
1324+
"error": format!("invalid JSON body: {err}"),
1325+
})),
1326+
);
1327+
}
1328+
}
1329+
};
1330+
1331+
// Extract an optional string field, rejecting the request with 400 if a
1332+
// field is present but not a string, rather than silently treating a
1333+
// malformed selector as absent (which could mint a token for the wrong
1334+
// workspace on this credential-minting endpoint).
1335+
let string_field =
1336+
|keys: &[&str]| -> Result<Option<String>, (axum::http::StatusCode, axum::Json<Value>)> {
1337+
for key in keys {
1338+
let Some(raw) = body.get(*key) else {
1339+
continue;
1340+
};
1341+
if raw.is_null() {
1342+
continue;
1343+
}
1344+
let Some(value) = raw.as_str() else {
1345+
return Err((
1346+
axum::http::StatusCode::BAD_REQUEST,
1347+
axum::Json(json!({
1348+
"success": false,
1349+
"error": format!("field '{key}' must be a string"),
1350+
})),
1351+
));
1352+
};
1353+
let value = value.trim();
1354+
return Ok((!value.is_empty()).then(|| value.to_string()));
1355+
}
1356+
Ok(None)
1357+
};
1358+
let workspace_id = match string_field(&["workspaceId", "workspace_id"]) {
1359+
Ok(value) => value,
1360+
Err(response) => return response,
1361+
};
1362+
let workspace_alias = match string_field(&["workspaceAlias", "workspace_alias"]) {
1363+
Ok(value) => value,
1364+
Err(response) => return response,
1365+
};
1366+
let name = match string_field(&["name"]) {
1367+
Ok(value) => value,
1368+
Err(response) => return response,
1369+
};
1370+
1371+
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
1372+
if state
1373+
.tx
1374+
.send(ListenApiRequest::CreateObserverToken {
1375+
workspace_id: workspace_id.map(WorkspaceId::from),
1376+
workspace_alias: workspace_alias.map(WorkspaceAlias::from),
1377+
name,
1378+
reply: reply_tx,
1379+
})
1380+
.await
1381+
.is_err()
1382+
{
1383+
return (
1384+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1385+
axum::Json(json!({ "success": false, "error": "internal channel closed" })),
1386+
);
1387+
}
1388+
1389+
match timeout(LISTEN_API_SEND_TIMEOUT, reply_rx).await {
1390+
Ok(Ok(Ok(val))) => (axum::http::StatusCode::OK, axum::Json(val)),
1391+
Ok(Ok(Err(err))) => {
1392+
let raw_error = err.to_string();
1393+
let status = if raw_error.starts_with("ambiguous_workspace:")
1394+
|| raw_error.starts_with("workspace_not_found:")
1395+
{
1396+
axum::http::StatusCode::BAD_REQUEST
1397+
} else {
1398+
axum::http::StatusCode::BAD_GATEWAY
1399+
};
1400+
let error = raw_error
1401+
.strip_prefix("ambiguous_workspace:")
1402+
.or_else(|| raw_error.strip_prefix("workspace_not_found:"))
1403+
.unwrap_or(&raw_error)
1404+
.to_string();
1405+
(
1406+
status,
1407+
axum::Json(json!({ "success": false, "error": error })),
1408+
)
1409+
}
1410+
Ok(Err(_)) => (
1411+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1412+
axum::Json(json!({ "success": false, "error": "internal reply dropped" })),
1413+
),
1414+
Err(_) => (
1415+
axum::http::StatusCode::GATEWAY_TIMEOUT,
1416+
axum::Json(json!({ "success": false, "error": "broker request timed out" })),
1417+
),
1418+
}
1419+
}
1420+
12881421
async fn listen_api_history_stats() -> axum::Json<Value> {
12891422
axum::Json(json!({
12901423
"messageCount": 0,
@@ -3071,6 +3204,156 @@ mod auth_tests {
30713204
);
30723205
}
30733206

3207+
#[tokio::test]
3208+
async fn observer_token_route_accepts_empty_body_with_json_content_type() {
3209+
// Dashboard clients may send `Content-Type: application/json` with an
3210+
// empty body since every field on this endpoint is optional; it must
3211+
// not be rejected before the handler runs (regression test for the
3212+
// `Json`/`Option<Json<_>>` extractor pitfall).
3213+
let (router, mut rx) = test_router(Some("secret"));
3214+
let replier = tokio::spawn(async move {
3215+
match rx.recv().await {
3216+
Some(ListenApiRequest::CreateObserverToken {
3217+
workspace_id,
3218+
workspace_alias,
3219+
name,
3220+
reply,
3221+
}) => {
3222+
assert_eq!(workspace_id, None);
3223+
assert_eq!(workspace_alias, None);
3224+
assert_eq!(name, None);
3225+
let _ = reply.send(Ok(json!({
3226+
"success": true,
3227+
"id": "ot_1",
3228+
"token": "ot_live_abc",
3229+
"name": "pear-dashboard-observer",
3230+
"scopes": ["stream:read"],
3231+
"workspace_id": "ws_1",
3232+
"workspace_alias": null,
3233+
})));
3234+
}
3235+
other => panic!("unexpected request: {:?}", other.map(|_| "other")),
3236+
}
3237+
});
3238+
3239+
let response = router
3240+
.oneshot(
3241+
Request::builder()
3242+
.uri("/api/observer-token")
3243+
.method("POST")
3244+
.header("x-api-key", "secret")
3245+
.header("content-type", "application/json")
3246+
.body(Body::empty())
3247+
.expect("request should build"),
3248+
)
3249+
.await
3250+
.expect("request should succeed");
3251+
3252+
assert_eq!(response.status(), StatusCode::OK);
3253+
replier.await.expect("replier should complete");
3254+
}
3255+
3256+
#[tokio::test]
3257+
async fn observer_token_route_accepts_missing_body_entirely() {
3258+
let (router, mut rx) = test_router(Some("secret"));
3259+
let replier = tokio::spawn(async move {
3260+
if let Some(ListenApiRequest::CreateObserverToken { reply, .. }) = rx.recv().await {
3261+
let _ = reply.send(Ok(json!({ "success": true, "id": "ot_1" })));
3262+
}
3263+
});
3264+
3265+
let response = router
3266+
.oneshot(
3267+
Request::builder()
3268+
.uri("/api/observer-token")
3269+
.method("POST")
3270+
.header("x-api-key", "secret")
3271+
.body(Body::empty())
3272+
.expect("request should build"),
3273+
)
3274+
.await
3275+
.expect("request should succeed");
3276+
3277+
assert_eq!(response.status(), StatusCode::OK);
3278+
replier.await.expect("replier should complete");
3279+
}
3280+
3281+
#[tokio::test]
3282+
async fn observer_token_route_rejects_non_string_workspace_id() {
3283+
let (router, mut rx) = test_router(Some("secret"));
3284+
3285+
let response = router
3286+
.oneshot(
3287+
Request::builder()
3288+
.uri("/api/observer-token")
3289+
.method("POST")
3290+
.header("x-api-key", "secret")
3291+
.header("content-type", "application/json")
3292+
.body(Body::from(json!({ "workspaceId": 123 }).to_string()))
3293+
.expect("request should build"),
3294+
)
3295+
.await
3296+
.expect("request should succeed");
3297+
3298+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3299+
let body = response_json(response).await;
3300+
assert_eq!(body["success"], json!(false));
3301+
assert!(
3302+
rx.try_recv().is_err(),
3303+
"malformed workspaceId should not enqueue a request"
3304+
);
3305+
}
3306+
3307+
#[tokio::test]
3308+
async fn observer_token_route_rejects_non_string_name() {
3309+
let (router, mut rx) = test_router(Some("secret"));
3310+
3311+
let response = router
3312+
.oneshot(
3313+
Request::builder()
3314+
.uri("/api/observer-token")
3315+
.method("POST")
3316+
.header("x-api-key", "secret")
3317+
.header("content-type", "application/json")
3318+
.body(Body::from(
3319+
json!({ "name": ["not", "a", "string"] }).to_string(),
3320+
))
3321+
.expect("request should build"),
3322+
)
3323+
.await
3324+
.expect("request should succeed");
3325+
3326+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3327+
assert!(
3328+
rx.try_recv().is_err(),
3329+
"malformed name should not enqueue a request"
3330+
);
3331+
}
3332+
3333+
#[tokio::test]
3334+
async fn observer_token_route_rejects_malformed_json_body() {
3335+
let (router, mut rx) = test_router(Some("secret"));
3336+
3337+
let response = router
3338+
.oneshot(
3339+
Request::builder()
3340+
.uri("/api/observer-token")
3341+
.method("POST")
3342+
.header("x-api-key", "secret")
3343+
.header("content-type", "application/json")
3344+
.body(Body::from("{not json"))
3345+
.expect("request should build"),
3346+
)
3347+
.await
3348+
.expect("request should succeed");
3349+
3350+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3351+
assert!(
3352+
rx.try_recv().is_err(),
3353+
"unparseable body should not enqueue a request"
3354+
);
3355+
}
3356+
30743357
#[tokio::test]
30753358
async fn ws_route_rejects_missing_api_key_when_auth_enabled() {
30763359
let (router, _rx) = test_router(Some("secret"));

crates/broker/src/relaycast/ws.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use relaycast::{
55
agent::DmOptions, format_registration_error,
66
retry_agent_registration as sdk_retry_agent_registration, ActionDefinition, ActionInvocation,
77
AgentClient, AgentRegistrationClient, AgentRegistrationError, AgentRegistrationRetryOutcome,
8-
CompleteInvocationRequest, MessageListQuery, RegisterActionRequest, RelayCast,
9-
RelayCastOptions, ReleaseAgentRequest,
8+
CompleteInvocationRequest, CreateObserverTokenRequest, MessageListQuery, ObserverToken,
9+
RegisterActionRequest, RelayCast, RelayCastOptions, ReleaseAgentRequest,
1010
};
1111
use serde_json::Value;
1212

@@ -191,6 +191,25 @@ impl RelaycastHttpClient {
191191
.map_err(|error| anyhow::anyhow!("{error}"))
192192
}
193193

194+
/// Mint a scoped, read-only observer token for this workspace. Used by
195+
/// the local HTTP API so callers (e.g. Pear's "Join as observer" link)
196+
/// can hand out a narrow `ot_live_...` credential instead of the raw
197+
/// `rk_live_...` workspace key, which grants full read/write/spawn
198+
/// access. The raw token material is only present on this response (and
199+
/// on `rotate_observer_token`'s), never on subsequent reads.
200+
pub async fn create_observer_token(
201+
&self,
202+
request: CreateObserverTokenRequest,
203+
) -> Result<ObserverToken> {
204+
let relay = self
205+
.relay_client()
206+
.context("SDK relay client not initialized")?;
207+
relay
208+
.create_observer_token(request)
209+
.await
210+
.map_err(|error| anyhow::anyhow!("{error}"))
211+
}
212+
194213
/// Fetch a single action invocation, including its `input`. The
195214
/// `action.invoked` WebSocket event omits the input payload, so the handler
196215
/// must read it back here before executing.

0 commit comments

Comments
 (0)