- 13001
"candidates": state.gateway.approver_candidates(), - 13002
})) - 13003
} - 13004
- 13005
#[derive(serde::Deserialize)] - 13006
struct GatewayApprovalsBody { - 13007
/// "deny" or "forward". - 13008
mode: String, - 13009
/// `<surface>:<chat>`. Required for "forward"; ignored for "deny". - 13010
#[serde(default)] - 13011
approver: Option<String>, - 13012
/// Seconds a forwarded gate waits before failing closed. Minimum 5, - 13013
/// matching `vak_config`'s own floor. - 13014
#[serde(default)] - 13015
timeout_secs: Option<u64>, - 13016
#[serde(default)] - 13017
scope: Option<ConfigScope>, - 13018
} - 13019
- 13020
/// `PUT /gateway/approvals` — set the policy, live and on disk. - 13021
/// - 13022
/// Validation happens here rather than being left to the config loader's - 13023
/// fallback: the loader's job is to make a bad file safe (it degrades - 13024
/// `forward` with no target to `deny` and warns), but an operator pressing - 13025
/// a button deserves a refusal that names the problem instead of a success - 13026
/// followed by a silently different setting. - 13027
async fn put_gateway_approvals( - 13028
State(state): State<AppState>, - 13029
Json(body): Json<GatewayApprovalsBody>, - 13030
) -> axum::response::Response { - 13031
use axum::response::IntoResponse; - 13032
let bad = |msg: &str| { - 13033
( - 13034
StatusCode::BAD_REQUEST, - 13035
Json(serde_json::json!({ "error": msg })), - 13036
) - 13037
.into_response() - 13038
}; - 13039
let mode = body.mode.trim(); - 13040
if !matches!(mode, "deny" | "forward") { - 13041
return bad("mode must be \"deny\" or \"forward\""); - 13042
} - 13043
let approver = body - 13044
.approver - 13045
.as_deref() - 13046
.map(str::trim) - 13047
.filter(|t| !t.is_empty()); - 13048
if mode == "forward" { - 13049
match approver { - 13050
None => { - 13051
return bad( - 13052
"forwarding needs an approver chat — the gate is announced there and \ - 13053
answered with \"yes\" or \"no\"", - 13054
); - 13055
} - 13056
Some(target) if !target.contains(':') => { - 13057
return bad("approver must be \"<surface>:<chat>\", e.g. \"telegram:12345678\""); - 13058
} - 13059
Some(_) => {} - 13060
} - 13061
} - 13062
if let Some(secs) = body.timeout_secs - 13063
&& !(5..=86_400).contains(&secs) - 13064
{ - 13065
return bad("timeout must be between 5 and 86400 seconds"); - 13066
} - 13067
- 13068
let scope = body.scope.unwrap_or(ConfigScope::Workspace); - 13069
let path = match scope.config_path(&state.core) { - 13070
Ok(path) => path, - 13071
Err(error) => { - 13072
return ( - 13073
StatusCode::INTERNAL_SERVER_ERROR, - 13074
Json(serde_json::json!({ "error": error })), - 13075
) - 13076
.into_response(); - 13077
} - 13078
}; - 13079
// Persist first. A policy that applied live but never reached disk is - 13080
// exactly the "I set it and it reverted" failure this endpoint exists - 13081
// to end, and it is worse than one that failed loudly. - 13082
if let Err(error) = vak_config::persist_gateway_approvals( - 13083
path, - 13084
Some(mode), - 13085
// "deny" clears the target rather than leaving a stale one behind - 13086
// that a later "forward" would silently reuse. - 13087
Some(if mode == "forward" { approver } else { None }), - 13088
body.timeout_secs, - 13089
) { - 13090
return ( - 13091
StatusCode::INTERNAL_SERVER_ERROR, - 13092
Json(serde_json::json!({ "error": error.to_string() })), - 13093
) - 13094
.into_response(); - 13095
} - 13096
- 13097
let previous = state.gateway.approvals_mode(); - 13098
let installed = state - 13099
.gateway - 13100
.set_approval_policy(crate::gateway::ApprovalPolicy { - 13101
approvals: mode.to_string(), - 13102
approver: approver.map(str::to_string), - 13103
timeout: std::time::Duration::from_secs( - 13104
body.timeout_secs - 13105
.unwrap_or_else(|| state.gateway.approval_timeout().as_secs()), - 13106
), - 13107
}); - 13108
- 13109
if previous != installed.approvals || installed.approvals == "forward" { - 13110
vak_core::security_events::record( - 13111
&state.core.sessions_home(), - 13112
vak_core::security_events::EventKind::ConfigChange, - 13113
"gateway_approvals_changed", - 13114
&format!( - 13115
"{previous} -> {} approver={} scope={}", - 13116
installed.approvals, - 13117
installed.approver.as_deref().unwrap_or("<none>"), - 13118
scope.label() - 13119
), - 13120
None, - 13121
); - 13122
state - 13123
.hub - 13124
.emit_config_changed("gateway_approvals", &installed.approvals); - 13125
} - 13126
- 13127
( - 13128
StatusCode::OK, - 13129
Json(serde_json::json!({ - 13130
"mode": installed.approvals, - 13131
"approver": installed.approver, - 13132
"timeout_secs": installed.timeout.as_secs(), - 13133
"forwarding": state.gateway.forward_mode(), - 13134
// The gateway being off makes a forward policy inert. Say so - 13135
// rather than reporting a grant the next inbound turn will not - 13136
// honour, which is the same class of lie `reach` exists to end. - 13137
"gateway_enabled": state.gateway.enabled, - 13138
})), - 13139
) - 13140
.into_response() - 13141
} - 13142
- 13143
// ---- Permission rules ------------------------------------------------------ - 13144
- 13145
/// `GET /config/permissions` — the effective rule lists the engine - 13146
/// evaluates, plus the selected layer's own, so a reader can tell an - 13147
/// inherited rule from one this scope set. - 13148
async fn get_permission_rules( - 13149
State(state): State<AppState>, - 13150
axum::extract::Query(q): axum::extract::Query<OptionalScopeQuery>, - 13151
) -> axum::response::Response { - 13152
use axum::response::IntoResponse; - 13153
let core = scoped_core!(&state, None, q.agent.as_deref()); - 13154
let scope = q.scope.unwrap_or(ConfigScope::Workspace); - 13155
// Recomputed from this resolved Core's own effective rules on every - 13156
// request rather than relying on any process-pinned cache — a Core - 13157
// resolved for a non-default Agent must not read the default Agent's - 13158
// runtime-pinned overrides, and vice versa. - 13159
let (allow, ask, deny) = core.effective_permission_rules(); - 13160
let layer = match scope - 13161
.config_path(&core) - 13162
.and_then(|path| read_config_layer(path.as_path())) - 13163
{ - 13164
Ok(layer) => layer, - 13165
Err(error) => { - 13166
return ( - 13167
StatusCode::INTERNAL_SERVER_ERROR, - 13168
Json(serde_json::json!({ "error": error })), - 13169
) - 13170
.into_response(); - 13171
} - 13172
}; - 13173
( - 13174
StatusCode::OK, - 13175
Json(serde_json::json!({ - 13176
"scope": scope.label(), - 13177
"effective": { "allow": allow, "ask": ask, "deny": deny }, - 13178
"layer": { - 13179
"allow": layer.allow, - 13180
"ask": layer.ask, - 13181
"deny": layer.deny, - 13182
}, - 13183
})), - 13184
) - 13185
.into_response() - 13186
} - 13187
- 13188
#[derive(serde::Deserialize)] - 13189
struct PermissionRulesBody { - 13190
/// Absent leaves that list alone; present replaces it wholesale. - 13191
#[serde(default)] - 13192
allow: Option<Vec<String>>, - 13193
#[serde(default)] - 13194
ask: Option<Vec<String>>, - 13195
#[serde(default)] - 13196
deny: Option<Vec<String>>, - 13197
#[serde(default)] - 13198
scope: Option<ConfigScope>, - 13199
#[serde(default)] - 13200
agent: Option<String>, - 13201
} - 13202
- 13203
/// `PUT /config/permissions` — replace rule lists in one layer. - 13204
/// - 13205
/// Every spec is parsed through the real `vak_permission::Rule::parse` - 13206
/// before anything is written, and the whole request is rejected if any - 13207
/// one of them fails. A half-applied rule set is a permission decision - 13208
/// nobody chose. - 13209
async fn put_permission_rules( - 13210
State(state): State<AppState>, - 13211
Json(body): Json<PermissionRulesBody>, - 13212
) -> axum::response::Response { - 13213
use axum::response::IntoResponse; - 13214
let core = scoped_core!(&state, None, body.agent.as_deref()); - 13215
for (list_name, list) in [ - 13216
("allow", &body.allow), - 13217
("ask", &body.ask), - 13218
("deny", &body.deny), - 13219
] { - 13220
let Some(list) = list else { continue }; - 13221
for spec in list { - 13222
if let Err(error) = vak_permission::Rule::parse(spec) { - 13223
return ( - 13224
StatusCode::BAD_REQUEST, - 13225
Json(serde_json::json!({ - 13226
"error": format!("{list_name}: {error}"), - 13227
"rule": spec, - 13228
})), - 13229
) - 13230
.into_response(); - 13231
} - 13232
} - 13233
} - 13234
let scope = body.scope.unwrap_or(ConfigScope::Workspace); - 13235
let path = match scope.config_path(&core) { - 13236
Ok(path) => path, - 13237
Err(error) => { - 13238
return ( - 13239
StatusCode::INTERNAL_SERVER_ERROR, - 13240
Json(serde_json::json!({ "error": error })), - 13241
) - 13242
.into_response(); - 13243
} - 13244
}; - 13245
if let Err(error) = vak_config::persist_permission_rules( - 13246
path, - 13247
body.allow.as_deref(), - 13248
body.ask.as_deref(), - 13249
body.deny.as_deref(), - 13250
) { - 13251
return ( - 13252
StatusCode::INTERNAL_SERVER_ERROR, - 13253
Json(serde_json::json!({ "error": error.to_string() })), - 13254
) - 13255
.into_response(); - 13256
} - 13257
// Re-merge both layers against this resolved Agent's own Core. Written - 13258
// rules are re-read from disk on every subsequent request through - 13259
// `resolve_scoped_core` (option (b): no pinned-cache optimization for a - 13260
// non-default Agent, since a freshly re-resolved Core would lose the - 13261
// pin anyway) — pinning onto the runtime override is kept only for the - 13262
// "vak" default/registered-session Core, where callers still read - 13263
// `effective_permission_rules()` off the very same long-lived instance - 13264
// within this same process. - 13265
let merged = match vak_config::load_with_trust(core.cwd(), core.project_config_trusted()) { - 13266
Ok(merged) => merged, - 13267
Err(error) => { - 13268
return ( - 13269
StatusCode::INTERNAL_SERVER_ERROR, - 13270
Json(serde_json::json!({ "error": error.to_string() })), - 13271
) - 13272
.into_response(); - 13273
} - 13274
}; - 13275
// Reject a set that the engine cannot compile, and do it BEFORE - 13276
// pinning: individually valid rules are all that was checked above, - 13277
// and the merge brings in the other layer's rules too. - 13278
let (allow, ask, deny) = ( - 13279
merged.allow.clone(), - 13280
merged.ask.clone(), - 13281
merged.deny.clone(), - 13282
); - 13283
core.apply_persisted_permission_rules(allow.clone(), ask.clone(), deny.clone()); - 13284
if let Err(error) = core.build_permission_engine(&[]) { - 13285
return ( - 13286
StatusCode::INTERNAL_SERVER_ERROR, - 13287
Json(serde_json::json!({ - 13288
"error": format!("merged rule set does not compile: {error}") - 13289
})), - 13290
) - 13291
.into_response(); - 13292
} - 13293
vak_core::security_events::record( - 13294
&core.sessions_home(), - 13295
vak_core::security_events::EventKind::ConfigChange, - 13296
"permission_rules_changed", - 13297
&format!( - 13298
"scope={} allow={} ask={} deny={}", - 13299
scope.label(), - 13300
allow.len(), - 13301
ask.len(), - 13302
deny.len() - 13303
), - 13304
None, - 13305
); - 13306
state - 13307
.hub - 13308
.emit_config_changed("permission_rules", scope.label()); - 13309
( - 13310
StatusCode::OK, - 13311
Json(serde_json::json!({ - 13312
"scope": scope.label(), - 13313
"effective": { "allow": allow, "ask": ask, "deny": deny }, - 13314
})), - 13315
) - 13316
.into_response() - 13317
} - 13318
- 13319
/// `core` is the Agent-scoped Core the mode is actually read from and - 13320
/// written to (so a PATCH scoped to a non-default Agent lands on that - 13321
/// Agent's own Core, not the process's default workspace); `state` is used - 13322
/// for the safety fallout below. Pooled channel Core invalidation stays - 13323
/// process-wide (those pooled Cores aren't cheaply attributable to one - 13324
/// Agent, and discarding an unaffected one just costs a fresh resolve on - 13325
/// its next message); but the live-session cancellation/approval-denial is - 13326
/// scoped to sessions running under *this* Core — a narrower ceiling must - 13327
/// not leave an already-running session under the same Agent holding a - 13328
/// wider one, but it has no bearing on a different Agent's own sessions. - 13329
fn apply_permission_mode( - 13330
core: &vak_core::Core, - 13331
state: &AppState, - 13332
mode: vak_config::PermissionMode, - 13333
persisted: bool, - 13334
) { - 13335
if core.effective_permission_mode() == mode { - 13336
return; - 13337
} - 13338
if persisted { - 13339
core.apply_persisted_permission_mode(mode); - 13340
} else { - 13341
core.set_permission_mode(mode); - 13342
} - 13343
// Warm per-channel instances captured their ceiling when they were - 13344
// built. Discard them so the next inbound message resolves a fresh one; - 13345
// without this a narrowed mode reached chats only when their idle - 13346
// window expired, which is up to half an hour of running under a - 13347
// ceiling that had already been revoked. - 13348
let dropped = state.gateway.core_pool.invalidate_pooled(); - 13349
if dropped > 0 { - 13350
vak_core::security_events::record( - 13351
&state.core.sessions_home(), - 13352
vak_core::security_events::EventKind::ConfigChange, - 13353
"core_pool_invalidated", - 13354
&format!("permission_mode={mode:?} dropped={dropped}"), - 13355
None, - 13356
); - 13357
} - 13358
let handles: Vec<Arc<SessionHandle>> = state - 13359
.sessions - 13360
.lock() - 13361
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13362
.values() - 13363
.filter(|handle| handle.core.cwd() == core.cwd()) - 13364
.cloned() - 13365
.collect(); - 13366
for handle in handles { - 13367
handle - 13368
.cancel - 13369
.lock() - 13370
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13371
.cancel(); - 13372
handle - 13373
.side_cancel - 13374
.lock() - 13375
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13376
.cancel(); - 13377
deny_pending_approvals(&handle); - 13378
let _ = state.gateway.deny_pending_for_session(&handle.id); - 13379
} - 13380
} - 13381
- 13382
fn refresh_control_plane(state: &AppState) { - 13383
let old_mode = state.core.effective_permission_mode(); - 13384
if let Ok(mode) = state.core.refresh_persisted_preferences() - 13385
&& !state.core.permission_mode_runtime_pinned() - 13386
&& mode != old_mode - 13387
{ - 13388
apply_permission_mode(&state.core, state, mode, true); - 13389
state - 13390
.hub - 13391
.emit_config_changed("permission_mode_refreshed", &format!("{mode:?}")); - 13392
} - 13393
} - 13394
- 13395
/// Picker data for provider/model UIs. Reports WHICH env var authenticates - 13396
/// each provider and whether it resolves right now — never the value. - 13397
async fn list_providers(State(state): State<AppState>) -> Json<serde_json::Value> { - 13398
refresh_control_plane(&state); - 13399
let route = state.core.effective_route(); - 13400
let mut providers = Vec::new(); - 13401
for name in state.core.provider_names() { - 13402
let requires_key = name != "ollama"; - 13403
let configured = state.core.provider_configured(&name); - 13404
let credential_ids = state.core.provider_credential_ids(&name); - 13405
let (project_key, user_key, process_key) = state.core.provider_key_sources(&name); - 13406
providers.push(serde_json::json!({ - 13407
"label": Core::provider_label(&name).unwrap_or(&name), - 13408
"name": name, - 13409
"env_var": Core::provider_env_var(&name), - 13410
"pool_env_var": pool_env_var(&name), - 13411
"pool_size": credential_ids.len(), - 13412
"credential_ids": credential_ids, - 13413
"requires_key": requires_key, - 13414
"configured": configured, - 13415
"key_in_project": project_key, - 13416
"key_in_user": user_key, - 13417
"key_in_process": process_key, - 13418
"key_source": if project_key { "project" } else if user_key { "user" } else if process_key { "process" } else { "none" }, - 13419
})); - 13420
} - 13421
Json(serde_json::json!({ - 13422
"current": route.provider, - 13423
"current_model": route.model, - 13424
"current_provider_source": route.provider_source, - 13425
"current_model_source": route.model_source, - 13426
"route_revision": route.revision, - 13427
"current_configured": state.core.provider_configured(&state.core.effective_provider()), - 13428
"providers": providers, - 13429
})) - 13430
} - 13431
- 13432
fn pool_env_var(provider: &str) -> Option<&'static str> { - 13433
Core::provider_pool_env_var(provider) - 13434
} - 13435
- 13436
#[derive(serde::Deserialize)] - 13437
struct ProviderRef { - 13438
provider: String, - 13439
#[serde(default)] - 13440
scope: Option<ConfigScope>, - 13441
} - 13442
- 13443
/// Revoke a provider key. Reports when the variable is still set in the - 13444
/// real environment, since that keeps the provider authenticated and no - 13445
/// app-level action can change it. - 13446
async fn delete_provider_key( - 13447
State(state): State<AppState>, - 13448
Json(body): Json<ProviderRef>, - 13449
) -> axum::response::Response { - 13450
let scope = body.scope.unwrap_or(ConfigScope::User); - 13451
match state - 13452
.core - 13453
.remove_provider_key_scoped(&body.provider, scope.is_workspace()) - 13454
{ - 13455
Ok(removed) => { - 13456
vak_core::security_events::record( - 13457
&state.core.sessions_home(), - 13458
vak_core::security_events::EventKind::ProviderKeyChange, - 13459
"provider_key_removed", - 13460
&format!( - 13461
"provider={} scope={} shadowed={}", - 13462
body.provider, - 13463
scope.label(), - 13464
removed.shadowed_by_env - 13465
), - 13466
None, - 13467
); - 13468
state - 13469
.hub - 13470
.emit_config_changed("provider_key_removed", &body.provider); - 13471
Json(serde_json::json!({ - 13472
"provider": body.provider, - 13473
"env_var": removed.env_var, - 13474
"configured": removed.shadowed_by_env, - 13475
"shadowed_by_env": removed.shadowed_by_env, - 13476
})) - 13477
.into_response() - 13478
} - 13479
Err(e) => ( - 13480
StatusCode::BAD_REQUEST, - 13481
Json(serde_json::json!({ "error": e.to_string() })), - 13482
) - 13483
.into_response(), - 13484
} - 13485
} - 13486
- 13487
/// Live model list for one provider, straight from its API using the key - 13488
/// currently configured for it. Reports the failure reason rather than - 13489
/// substituting a stale hard-coded list. - 13490
async fn discover_models( - 13491
State(state): State<AppState>, - 13492
axum::extract::Path(name): axum::extract::Path<String>, - 13493
) -> axum::response::Response { - 13494
if !Core::provider_known(&name) { - 13495
return ( - 13496
StatusCode::NOT_FOUND, - 13497
Json(serde_json::json!({ "error": format!("unknown provider '{name}'") })), - 13498
) - 13499
.into_response(); - 13500
} - 13501
match state.core.discover_models(&name).await { - 13502
Ok(models) => { - 13503
if name == "bedrock" { - 13504
match state.core.bedrock_model_availability(&models).await { - 13505
Ok(availability) => Json(serde_json::json!({ "provider": name, "models": models, "availability": availability })).into_response(), - 13506
Err(e) => Json(serde_json::json!({ "provider": name, "models": models, "availability_error": e.to_string() })).into_response(), - 13507
} - 13508
} else { - 13509
Json(serde_json::json!({ "provider": name, "models": models })).into_response() - 13510
} - 13511
} - 13512
Err(e) => { - 13513
let mut body = provider_error_body(&e); - 13514
body["provider"] = serde_json::Value::String(name); - 13515
(StatusCode::BAD_GATEWAY, Json(body)).into_response() - 13516
} - 13517
} - 13518
} - 13519
- 13520
async fn model_availability( - 13521
State(state): State<AppState>, - 13522
axum::extract::Path(name): axum::extract::Path<String>, - 13523
) -> axum::response::Response { - 13524
if name != "bedrock" { - 13525
return ( - 13526
StatusCode::NOT_FOUND, - 13527
Json(serde_json::json!({"error":"availability is only supported for bedrock"})), - 13528
) - 13529
.into_response(); - 13530
} - 13531
let models = match state.core.discover_models("bedrock").await { - 13532
Ok(models) => models, - 13533
Err(e) => { - 13534
let mut body = provider_error_body(&e); - 13535
body["provider"] = serde_json::Value::String(name); - 13536
return (StatusCode::BAD_GATEWAY, Json(body)).into_response(); - 13537
} - 13538
}; - 13539
match state.core.bedrock_model_availability(&models).await { - 13540
Ok(availability) => { - 13541
Json(serde_json::json!({"provider":name,"models":availability})).into_response() - 13542
} - 13543
Err(e) => ( - 13544
StatusCode::BAD_GATEWAY, - 13545
Json(serde_json::json!({"provider":name,"error":e.to_string()})), - 13546
) - 13547
.into_response(), - 13548
} - 13549
} - 13550
- 13551
/// Read provider-published account metadata without returning credentials. - 13552
async fn provider_status( - 13553
State(state): State<AppState>, - 13554
axum::extract::Path(name): axum::extract::Path<String>, - 13555
) -> axum::response::Response { - 13556
if !Core::provider_known(&name) { - 13557
return ( - 13558
StatusCode::NOT_FOUND, - 13559
Json(serde_json::json!({ "error": format!("unknown provider '{name}'") })), - 13560
) - 13561
.into_response(); - 13562
} - 13563
match state.core.provider_status(&name).await { - 13564
Ok(status) => Json(status).into_response(), - 13565
Err(e) => ( - 13566
StatusCode::BAD_GATEWAY, - 13567
Json(serde_json::json!({ "provider": name, "error": e.to_string() })), - 13568
) - 13569
.into_response(), - 13570
} - 13571
} - 13572
- 13573
#[derive(serde::Deserialize)] - 13574
struct ProviderKeyBody { - 13575
provider: String, - 13576
key: String, - 13577
#[serde(default)] - 13578
scope: Option<ConfigScope>, - 13579
} - 13580
- 13581
/// Persists a credential to the Shared secret scope and makes it - 13582
/// effective immediately. The key is accepted once and never echoed back. - 13583
async fn put_provider_key( - 13584
State(state): State<AppState>, - 13585
Json(body): Json<ProviderKeyBody>, - 13586
) -> axum::response::Response { - 13587
let scope = body.scope.unwrap_or(ConfigScope::User); - 13588
match state - 13589
.core - 13590
.set_provider_key_scoped(&body.provider, &body.key, scope.is_workspace()) - 13591
{ - 13592
Ok(env_var) => { - 13593
vak_core::security_events::record( - 13594
&state.core.sessions_home(), - 13595
vak_core::security_events::EventKind::ProviderKeyChange, - 13596
"provider_key_set", - 13597
&format!("provider={} scope={}", body.provider, scope.label()), - 13598
None, - 13599
); - 13600
state - 13601
.hub - 13602
.emit_config_changed("provider_key_set", &body.provider); - 13603
Json(serde_json::json!({ - 13604
"provider": body.provider, - 13605
"env_var": env_var, - 13606
"configured": true, - 13607
})) - 13608
.into_response() - 13609
} - 13610
Err(e) => ( - 13611
StatusCode::BAD_REQUEST, - 13612
Json(serde_json::json!({ "error": e.to_string() })), - 13613
) - 13614
.into_response(), - 13615
} - 13616
} - 13617
- 13618
// ---- Distributed event bus (vak-bus, docs/design/53) ------------------- - 13619
- 13620
#[derive(serde::Deserialize)] - 13621
struct BusConfigBody { - 13622
nats_url: Option<String>, - 13623
/// NATS credentials JWT. Stored in the workspace secret scope and read - 13624
/// on next server start. Never returned by GET. - 13625
#[serde(default)] - 13626
nats_credentials_jwt: Option<String>, - 13627
/// NATS nkey seed. Stored in the workspace secret scope and read - 13628
/// on next server start. Never returned by GET. - 13629
#[serde(default)] - 13630
nats_nkey_seed: Option<String>, - 13631
/// Name of the env var holding the workspace encryption secret. - 13632
#[serde(default)] - 13633
workspace_secret_env: Option<String>, - 13634
} - 13635
- 13636
/// GET /config/bus — bus status and configuration (non-secret fields only). - 13637
async fn get_bus_config(State(state): State<AppState>) -> axum::response::Response { - 13638
let cfg = state.core.config().server.bus.clone(); - 13639
let status = state.hub.bus_status(); - 13640
Json(serde_json::json!({ - 13641
"nats_url": cfg.nats_url, - 13642
"encrypted": cfg.workspace_secret.is_some(), - 13643
"runtime": status, - 13644
})) - 13645
.into_response() - 13646
} - 13647
- 13648
/// PUT /config/bus — sets the bus for this workspace and applies it now - 13649
/// (invariant 31). The NATS URL and the workspace-secret variable's name - 13650
/// go to the project config's `[server.bus]`; the credentials JWT and nkey - 13651
/// seed go to the project secret scope through `vak_config::credentials`, - 13652
/// never to a file. Credentials are never returned by GET. - 13653
async fn put_bus_config( - 13654
State(state): State<AppState>, - 13655
Json(body): Json<BusConfigBody>, - 13656
) -> axum::response::Response { - 13657
if body.nats_url.is_none() - 13658
&& body.nats_credentials_jwt.is_none() - 13659
&& body.nats_nkey_seed.is_none() - 13660
&& body.workspace_secret_env.is_none() - 13661
{ - 13662
return ( - 13663
StatusCode::BAD_REQUEST, - 13664
Json(serde_json::json!({ - 13665
"error": "at least one of nats_url, nats_credentials_jwt, nats_nkey_seed or workspace_secret_env must be provided" - 13666
})), - 13667
) - 13668
.into_response(); - 13669
} - 13670
let core = state.core.clone(); - 13671
let project_scope = core.cwd().join(".env"); - 13672
let mut changed = Vec::new(); - 13673
for (var, value) in [ - 13674
(vak_config::BUS_NATS_JWT_VAR, &body.nats_credentials_jwt), - 13675
(vak_config::BUS_NATS_NKEY_SEED_VAR, &body.nats_nkey_seed), - 13676
] { - 13677
let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else { - 13678
continue; - 13679
}; - 13680
if let Err(error) = vak_config::upsert_env_file(&project_scope, var, value) { - 13681
return ( - 13682
StatusCode::INTERNAL_SERVER_ERROR, - 13683
Json(serde_json::json!({ "error": format!("could not store {var}: {error}") })), - 13684
) - 13685
.into_response(); - 13686
} - 13687
changed.push(var); - 13688
} - 13689
if let Err(error) = vak_config::persist_bus_settings( - 13690
core.cwd().join(".vak").join("config.toml"), - 13691
body.nats_url.as_deref().map(Some), - 13692
body.workspace_secret_env.as_deref().map(Some), - 13693
) { - 13694
return ( - 13695
StatusCode::INTERNAL_SERVER_ERROR, - 13696
Json(serde_json::json!({ "error": error.to_string() })), - 13697
) - 13698
.into_response(); - 13699
} - 13700
for key in changed - 13701
.iter() - 13702
.copied() - 13703
.chain(body.nats_url.as_ref().map(|_| "nats_url")) - 13704
.chain( - 13705
body.workspace_secret_env - 13706
.as_ref() - 13707
.map(|_| "workspace_secret_env"), - 13708
) - 13709
{ - 13710
state.hub.emit_config_changed("bus_config_set", key); - 13711
} - 13712
vak_core::security_events::record( - 13713
&core.sessions_home(), - 13714
vak_core::security_events::EventKind::ConfigChange, - 13715
"bus_config_set", - 13716
&format!("secrets={}", changed.join(", ")), - 13717
None, - 13718
); - 13719
let current = core.config().server.bus.clone(); - 13720
let live = vak_config::BusResolved { - 13721
nats_url: body.nats_url.clone().or(current.nats_url), - 13722
workspace_secret: match body.workspace_secret_env.as_deref() { - 13723
Some(name) => std::env::var(name).ok().map(String::into_bytes), - 13724
None => current.workspace_secret, - 13725
}, - 13726
}; - 13727
install_server_bus(&core, &live).await; - 13728
Json(serde_json::json!({ - 13729
"configured": true, - 13730
"runtime": state.hub.bus_status(), - 13731
})) - 13732
.into_response() - 13733
} - 13734
- 13735
/// DELETE /config/bus — removes the bus's secrets from the project secret - 13736
/// scope and its settings from the project config, and returns the live - 13737
/// bus to local-only. - 13738
async fn delete_bus_config(State(state): State<AppState>) -> axum::response::Response { - 13739
let core = state.core.clone(); - 13740
let project_scope = core.cwd().join(".env"); - 13741
for var in [ - 13742
vak_config::BUS_NATS_JWT_VAR, - 13743
vak_config::BUS_NATS_NKEY_SEED_VAR, - 13744
] { - 13745
let _ = vak_config::remove_env_file_key(&project_scope, var); - 13746
} - 13747
if let Err(error) = vak_config::persist_bus_settings( - 13748
core.cwd().join(".vak").join("config.toml"), - 13749
Some(None), - 13750
Some(None), - 13751
) { - 13752
return ( - 13753
StatusCode::INTERNAL_SERVER_ERROR, - 13754
Json(serde_json::json!({ "error": error.to_string() })), - 13755
) - 13756
.into_response(); - 13757
} - 13758
install_server_bus(&core, &vak_config::BusResolved::default()).await; - 13759
state.hub.emit_config_changed("bus_config_cleared", "all"); - 13760
Json(serde_json::json!({ "configured": false })).into_response() - 13761
} - 13762
- 13763
#[derive(serde::Deserialize)] - 13764
struct TelegramTokenBody { - 13765
token: String, - 13766
} - 13767
- 13768
// ---- Multi-bot (docs/design/34, multi-bot-per-channel) -------------------- - 13769
- 13770
fn bot_env_var(id: &str) -> String { - 13771
// A dedicated env var per bot id, distinct from the legacy per-surface - 13772
// slots (`TELEGRAM_BOT_TOKEN` etc.) so a second bot never overwrites - 13773
// the first one's token in the shared secret scope. - 13774
format!("BOT_TOKEN__{}", id.to_uppercase().replace('-', "_")) - 13775
} - 13776
- 13777
async fn list_bots(State(state): State<AppState>) -> Json<serde_json::Value> { - 13778
// `token_configured` rather than the token: a bot's credential is - 13779
// never returned once set (invariant 23). Every surface needs to know - 13780
// *whether* a bot can authenticate — that is what "is this bot ready?" - 13781
// means — without any of them being able to read the secret. - 13782
let bots: Vec<serde_json::Value> = state - 13783
.gateway - 13784
.bots_snapshot() - 13785
.into_iter() - 13786
.map(|bot| { - 13787
let configured = vak_config::get_var(&bot.token_env).is_some(); - 13788
let mut value = serde_json::to_value(&bot).unwrap_or_else(|_| serde_json::json!({})); - 13789
if let Some(map) = value.as_object_mut() { - 13790
map.insert("token_configured".into(), serde_json::json!(configured)); - 13791
} - 13792
value - 13793
}) - 13794
.collect(); - 13795
Json(serde_json::json!({ "bots": bots })) - 13796
} - 13797
- 13798
#[derive(serde::Deserialize)] - 13799
struct CreateBotBody { - 13800
id: String, - 13801
surface: String, - 13802
label: String, - 13803
#[serde(default)] - 13804
agent_id: Option<String>, - 13805
} - 13806
- 13807
async fn create_bot( - 13808
State(state): State<AppState>, - 13809
Json(body): Json<CreateBotBody>, - 13810
) -> axum::response::Response { - 13811
let id = body.id.trim(); - 13812
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - 13813
return ( - 13814
StatusCode::BAD_REQUEST, - 13815
Json(serde_json::json!({ "error": "bot id must be non-empty alphanumeric/hyphen" })), - 13816
) - 13817
.into_response(); - 13818
} - 13819
if !vak_core::Core::is_surface(&body.surface) { - 13820
return ( - 13821
StatusCode::BAD_REQUEST, - 13822
Json( - 13823
serde_json::json!({ "error": format!("unknown chat surface '{}'", body.surface) }), - 13824
), - 13825
) - 13826
.into_response(); - 13827
} - 13828
if state.gateway.bot_get(id).is_some() { - 13829
return ( - 13830
StatusCode::CONFLICT, - 13831
Json(serde_json::json!({ "error": format!("bot '{id}' already exists") })), - 13832
) - 13833
.into_response(); - 13834
} - 13835
let bot = crate::gateway::Bot { - 13836
id: id.to_string(), - 13837
surface: body.surface.clone(), - 13838
label: if body.label.trim().is_empty() { - 13839
id.to_string() - 13840
} else { - 13841
body.label.trim().to_string() - 13842
}, - 13843
token_env: bot_env_var(id), - 13844
agent_id: body.agent_id.and_then(|id| { - 13845
let trimmed = id.trim(); - 13846
if trimmed.is_empty() || trimmed == "vak" { - 13847
None - 13848
} else { - 13849
Some(trimmed.to_string()) - 13850
} - 13851
}), - 13852
..Default::default() - 13853
}; - 13854
state.gateway.bot_upsert(&state.core, bot.clone()); - 13855
state.hub.emit_config_changed("bot_created", id); - 13856
// Creating a bot writes `bots.json`. It does not register an OS - 13857
// service: activation is a separate, explicit act (see - 13858
// `service_control`), so a routine edit never mutates the machine's - 13859
// service manager behind the operator's back. - 13860
Json(serde_json::json!({ "bot": bot, "activation_required": true })).into_response() - 13861
} - 13862
- 13863
#[derive(serde::Deserialize, Default)] - 13864
struct UpdateBotBody { - 13865
#[serde(default)] - 13866
label: Option<String>, - 13867
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13868
agent_id: Option<Option<String>>, - 13869
#[serde(default)] - 13870
policy: Option<vak_config::ChannelPolicy>, - 13871
/// Absent (field simply not sent) leaves the current mode alone; - 13872
/// explicit `null` clears it back to "follows the project"; a string - 13873
/// sets it. See `gateway::deserialize_present` for why the plain - 13874
/// `Option<Option<T>>` shape needs the custom deserializer to make - 13875
/// that distinction actually work. - 13876
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13877
permission_mode: Option<Option<String>>, - 13878
/// See `permission_mode` above. - 13879
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13880
route: Option<Option<crate::gateway::AllowlistRoute>>, - 13881
/// See `permission_mode` above. - 13882
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13883
workspace: Option<Option<String>>, - 13884
/// See `permission_mode` above: absent leaves the bot's voice alone, - 13885
/// explicit `null` clears it back to inherit, a `VoiceConfig` object - 13886
/// sets it. - 13887
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13888
voice: Option<Option<vak_config::VoiceConfig>>, - 13889
/// This bot's prompt tier (docs/design/45-prompt-layers.md). Absent - 13890
/// leaves it alone; an object replaces it. Its `identity` block is also - 13891
/// what drives this bot's spoken persona, so the two cannot drift. - 13892
#[serde(default)] - 13893
prompt: Option<vak_core::prompts::LayerContent>, - 13894
} - 13895
- 13896
async fn update_bot( - 13897
State(state): State<AppState>, - 13898
axum::extract::Path(id): axum::extract::Path<String>, - 13899
Json(body): Json<UpdateBotBody>, - 13900
) -> axum::response::Response { - 13901
let Some(mut bot) = state.gateway.bot_get(&id) else { - 13902
return ( - 13903
StatusCode::NOT_FOUND, - 13904
Json(serde_json::json!({ "error": format!("no bot '{id}'") })), - 13905
) - 13906
.into_response(); - 13907
}; - 13908
if let Some(label) = body.label { - 13909
bot.label = label; - 13910
} - 13911
if let Some(agent_id) = body.agent_id { - 13912
bot.agent_id = match agent_id { - 13913
None => None, - 13914
Some(raw) if raw.trim().is_empty() || raw.trim() == "vak" => None, - 13915
Some(raw) => Some(raw.trim().to_string()), - 13916
}; - 13917
} - 13918
if let Some(policy) = body.policy { - 13919
bot.policy = policy; - 13920
} - 13921
if let Some(raw) = body.permission_mode { - 13922
match raw { - 13923
None => bot.permission_mode = None, - 13924
Some(raw) if raw.trim().is_empty() => bot.permission_mode = None, - 13925
Some(raw) => match crate::parse_mode(raw.trim()) { - 13926
Some(mode) => bot.permission_mode = Some(mode), - 13927
None => { - 13928
return ( - 13929
StatusCode::BAD_REQUEST, - 13930
Json( - 13931
serde_json::json!({ "error": format!("unknown permission_mode '{raw}'") }), - 13932
), - 13933
) - 13934
.into_response(); - 13935
} - 13936
}, - 13937
} - 13938
} - 13939
if let Some(route) = body.route { - 13940
bot.route = route; - 13941
} - 13942
if let Some(ws) = body.workspace { - 13943
bot.workspace = match ws { - 13944
None => None, - 13945
Some(ws) if ws.trim().is_empty() => None, - 13946
Some(ws) => Some(PathBuf::from(ws.trim())), - 13947
}; - 13948
} - 13949
if let Some(voice) = body.voice { - 13950
if let Some(Err(error)) = voice.as_ref().map(voice::check_tier) { - 13951
return ( - 13952
StatusCode::BAD_REQUEST, - 13953
Json(serde_json::json!({ "error": error })), - 13954
) - 13955
.into_response(); - 13956
} - 13957
bot.voice = voice; - 13958
} - 13959
if let Some(prompt) = body.prompt { - 13960
bot.prompt = prompt; - 13961
} - 13962
state.gateway.bot_upsert(&state.core, bot.clone()); - 13963
state.hub.emit_config_changed("bot_updated", &id); - 13964
Json(serde_json::json!({ "bot": bot })).into_response() - 13965
} - 13966
- 13967
async fn delete_bot( - 13968
State(state): State<AppState>, - 13969
axum::extract::Path(id): axum::extract::Path<String>, - 13970
) -> StatusCode { - 13971
if state.gateway.bot_remove(&state.core, &id) { - 13972
// No service-manager call here. The bridge watches its own - 13973
// credential (`surfaces::CredentialWatch`) and stops when it goes - 13974
// away, so removal takes effect without a handler orchestrating - 13975
// anything. Its unit is cleaned up at the next activation. - 13976
state.hub.emit_config_changed("bot_deleted", &id); - 13977
StatusCode::OK - 13978
} else { - 13979
StatusCode::NOT_FOUND - 13980
} - 13981
} - 13982
- 13983
async fn put_bot_id_token( - 13984
State(state): State<AppState>, - 13985
axum::extract::Path(id): axum::extract::Path<String>, - 13986
Json(body): Json<TelegramTokenBody>, - 13987
) -> axum::response::Response { - 13988
let Some(bot) = state.gateway.bot_get(&id) else { - 13989
return ( - 13990
StatusCode::NOT_FOUND, - 13991
Json(serde_json::json!({ "error": format!("no bot '{id}'") })), - 13992
) - 13993
.into_response(); - 13994
}; - 13995
match state.core.set_bot_token(&bot.token_env, &body.token) { - 13996
Ok(env_var) => { - 13997
vak_core::security_events::record( - 13998
&state.core.sessions_home(), - 13999
vak_core::security_events::EventKind::ProviderKeyChange, - 14000
"bot_token_set",
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.