- 1
//! Chat-surface transport adapters: long-polling or webhook clients that - 2
//! bridge an external surface into `POST /gateway/inbound` and deliver the - 3
//! reply back. - 4
//! - 5
//! Deliberately distinct from `vak-delivery`'s same-named modules, which own - 6
//! the *markup projection* for each surface (Telegram HTML, Slack mrkdwn, - 7
//! Discord markdown). Transport lives here; formatting lives there. Both sets - 8
//! previously sat at `src/telegram.rs` in their respective crates, which read - 9
//! as duplication until you opened both. - 10
- 11
pub mod discord; - 12
pub mod slack; - 13
pub mod telegram; - 14
- 15
fn prepared_packet( - 16
body: serde_json::Value, - 17
surface: &str, - 18
) -> Result<vak_delivery::DeliveryPacket, String> { - 19
let packet: vak_delivery::DeliveryPacket = serde_json::from_value(body["delivery"].clone()) - 20
.map_err(|_| "gateway did not supply a valid delivery packet".to_string())?; - 21
if packet.schema_version != vak_delivery::DELIVERY_SCHEMA_VERSION || packet.surface != surface { - 22
return Err("gateway supplied an incompatible delivery packet".into()); - 23
} - 24
validate_adaptive_fallbacks(&packet)?; - 25
Ok(packet) - 26
} - 27
- 28
/// Every constrained surface must retain the exact textual fallback attached - 29
/// to a native adaptive tree. This is a transport-boundary check shared by - 30
/// Telegram, Slack, Discord, and future bridges: a malformed packet cannot - 31
/// silently turn a rich result into an inaccessible or non-voice-safe reply. - 32
fn validate_adaptive_fallbacks(packet: &vak_delivery::DeliveryPacket) -> Result<(), String> { - 33
let Some(presentation) = packet.presentation.as_ref() else { - 34
return Ok(()); - 35
}; - 36
for item in &presentation.items { - 37
if let vak_delivery::OutputContent::Adaptive { fallback_text, .. } = &item.content - 38
&& (fallback_text.trim().is_empty() || item.fallback_text != *fallback_text) - 39
{ - 40
return Err(format!( - 41
"adaptive presentation item '{}' has an invalid fallback", - 42
item.id - 43
)); - 44
} - 45
} - 46
Ok(()) - 47
} - 48
- 49
fn prepared_chunks(body: serde_json::Value, surface: &str) -> Result<Vec<String>, String> { - 50
let packet = prepared_packet(body, surface)?; - 51
if packet.chunks.is_empty() || packet.chunks.iter().any(String::is_empty) { - 52
return Err("gateway supplied an empty delivery packet".into()); - 53
} - 54
Ok(packet.chunks) - 55
} - 56
- 57
/// Watches a bridge's own credential and reports when it changes. - 58
/// - 59
/// A bridge used to read its token once at startup, which made revoking - 60
/// one ineffective until the process restarted — and that is why an API - 61
/// handler ended up shelling out to `launchctl` to bounce a bridge. A - 62
/// request handler orchestrating the platform service manager is the wrong - 63
/// shape twice over: it blocks a request on a subprocess (AGENTS.md - 64
/// invariant 26), and it makes revocation depend on an orchestration side - 65
/// effect instead of on the data. - 66
/// - 67
/// So the bridge watches its own credential. Revocation and rotation - 68
/// become facts about the credential store that the bridge notices within - 69
/// one poll cycle, on every platform, with nothing else involved — and no - 70
/// handler touches the service manager at all. - 71
pub struct CredentialWatch { - 72
env_var: String, - 73
seen: String, - 74
} - 75
- 76
/// What the credential looks like now, relative to what the bridge started - 77
/// with. - 78
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 79
pub enum CredentialState { - 80
/// Same value the bridge is already authenticating with. - 81
Unchanged, - 82
/// Rotated. The bridge should exit so the service manager restarts it - 83
/// with the new value — a lifecycle decision the bridge makes about - 84
/// itself, never one an API handler makes for it. - 85
Rotated, - 86
/// Gone. The bridge must stop using the old value immediately. - 87
Revoked, - 88
} - 89
- 90
impl CredentialWatch { - 91
pub fn new(env_var: impl Into<String>, current: impl Into<String>) -> Self { - 92
Self { - 93
env_var: env_var.into(), - 94
seen: current.into(), - 95
} - 96
} - 97
- 98
/// Re-read the credential from the canonical Shared secret scope. - 99
/// - 100
/// Resolves through the credential store rather than the process - 101
/// environment cache: the whole point is to see a change written by - 102
/// another process after this one started. A real environment - 103
/// variable still wins, matching the precedence every other secret - 104
/// lookup uses (invariant 8). - 105
pub fn check(&self) -> CredentialState { - 106
let current = std::env::var(&self.env_var).ok().or_else(|| { - 107
vak_config::user_env_path() - 108
.and_then(|path| vak_config::read_env_file_var(&path, &self.env_var)) - 109
}); - 110
match current { - 111
None => CredentialState::Revoked, - 112
Some(value) if value.trim().is_empty() => CredentialState::Revoked, - 113
Some(value) if value == self.seen => CredentialState::Unchanged, - 114
Some(_) => CredentialState::Rotated, - 115
} - 116
} - 117
} - 118
- 119
#[cfg(test)] - 120
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 121
mod tests { - 122
use super::*; - 123
- 124
#[test] - 125
fn an_unchanged_credential_reports_unchanged() { - 126
// Uses the process environment, which wins over the file. - 127
let watch = CredentialWatch::new("PATH", std::env::var("PATH").unwrap_or_default()); - 128
assert_eq!(watch.check(), CredentialState::Unchanged); - 129
} - 130
- 131
#[test] - 132
fn a_credential_that_does_not_exist_reads_as_revoked() { - 133
let watch = CredentialWatch::new("VAK_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST", "old"); - 134
assert_eq!(watch.check(), CredentialState::Revoked); - 135
} - 136
- 137
#[test] - 138
fn a_changed_credential_reads_as_rotated() { - 139
let watch = CredentialWatch::new("PATH", "something-else-entirely"); - 140
assert_eq!(watch.check(), CredentialState::Rotated); - 141
} - 142
- 143
#[test] - 144
fn prepared_packet_rejects_wrong_surface_before_a_constrained_bridge_sends_it() { - 145
let body = serde_json::json!({ - 146
"delivery": { - 147
"schema_version": vak_delivery::DELIVERY_SCHEMA_VERSION, - 148
"job_id": "job", - 149
"target": "chat", - 150
"surface": "slack", - 151
"kind": "assistant", - 152
"payload": {"type": "text", "value": "hello"}, - 153
"fallback_markdown": "hello", - 154
"chunks": ["hello"], - 155
"actions": [], - 156
"coverage": [], - 157
"diagnostics": [] - 158
} - 159
}); - 160
assert!(prepared_packet(body, "telegram").is_err()); - 161
} - 162
- 163
#[test] - 164
fn prepared_packet_rejects_adaptive_output_without_matching_voice_fallback() { - 165
let body = serde_json::json!({ - 166
"delivery": { - 167
"schema_version": vak_delivery::DELIVERY_SCHEMA_VERSION, - 168
"job_id": "job", - 169
"target": "chat", - 170
"surface": "telegram", - 171
"kind": "assistant", - 172
"payload": {"type": "text", "value": "hello"}, - 173
"fallback_markdown": "hello", - 174
"chunks": ["hello"], - 175
"actions": [], - 176
"coverage": [], - 177
"diagnostics": [], - 178
"presentation": { - 179
"schema_version": vak_delivery::PRESENTATION_SCHEMA_VERSION, - 180
"session_id": "", - 181
"cursor": null, - 182
"items": [{ - 183
"id": "job/answer", - 184
"timestamp": "", - 185
"turn_id": "job", - 186
"role": "assistant", - 187
"kind": "outcome", - 188
"status": "succeeded", - 189
"outcome": null, - 190
"content": {"type": "adaptive", "tree": { - 191
"schema_version": 1, - 192
"spec_id": "fixture", - 193
"revision": 1, - 194
"digest": "digest", - 195
"root": {"primitive": "title", "props": {}, "children": []}, - 196
"accessibility_summary": "A result", - 197
"coverage": {"rendered_paths": [], "omitted_paths": []} - 198
}, "fallback_text": "secret"}, - 199
"provenance": null, - 200
"actions": [], - 201
"fallback_text": "different" - 202
}], - 203
"diagnostics": [], - 204
"goal": null - 205
} - 206
} - 207
}); - 208
let error = - 209
prepared_packet(body, "telegram").expect_err("mismatched fallback must fail closed"); - 210
assert!(error.contains("invalid fallback")); - 211
} - 212
- 213
#[test] - 214
fn prepared_packet_rejects_adaptive_output_with_empty_fallback() { - 215
let body = serde_json::json!({ - 216
"delivery": { - 217
"schema_version": vak_delivery::DELIVERY_SCHEMA_VERSION, - 218
"job_id": "job", - 219
"target": "chat", - 220
"surface": "telegram", - 221
"kind": "assistant", - 222
"payload": {"type": "text", "value": "hello"}, - 223
"fallback_markdown": "hello", - 224
"chunks": ["hello"], - 225
"actions": [], - 226
"coverage": [], - 227
"diagnostics": [], - 228
"presentation": { - 229
"schema_version": vak_delivery::PRESENTATION_SCHEMA_VERSION, - 230
"session_id": "", - 231
"cursor": null, - 232
"items": [{ - 233
"id": "job/answer", - 234
"timestamp": "", - 235
"turn_id": "job", - 236
"role": "assistant", - 237
"kind": "outcome", - 238
"status": "succeeded", - 239
"outcome": null, - 240
"content": {"type": "adaptive", "tree": { - 241
"schema_version": 1, - 242
"spec_id": "fixture", - 243
"revision": 1, - 244
"digest": "digest", - 245
"root": {"primitive": "title", "props": {}, "children": []}, - 246
"accessibility_summary": "A result", - 247
"coverage": {"rendered_paths": [], "omitted_paths": []} - 248
}, "fallback_text": ""}, - 249
"provenance": null, - 250
"actions": [], - 251
"fallback_text": "" - 252
}], - 253
"diagnostics": [], - 254
"goal": null - 255
} - 256
} - 257
}); - 258
let error = prepared_packet(body, "telegram") - 259
.expect_err("empty adaptive fallback must fail closed"); - 260
assert!(error.contains("invalid fallback")); - 261
} - 262
- 263
#[test] - 264
fn every_constrained_surface_accepts_the_same_native_adaptive_fixture() { - 265
// This is intentionally a transport fixture, not a domain scenario: - 266
// all bridges receive the same tree and must preserve its exact text - 267
// fallback before applying their own markup projection. - 268
for surface in ["telegram", "slack", "discord", "webhook"] { - 269
let body = serde_json::json!({ - 270
"delivery": { - 271
"schema_version": vak_delivery::DELIVERY_SCHEMA_VERSION, - 272
"job_id": "fixture-job", - 273
"target": "fixture-chat", - 274
"surface": surface, - 275
"kind": "assistant", - 276
"payload": {"type": "text", "value": "A reusable result"}, - 277
"fallback_markdown": "A reusable result", - 278
"chunks": ["A reusable result"], - 279
"actions": [], - 280
"coverage": [], - 281
"diagnostics": [], - 282
"presentation": { - 283
"schema_version": vak_delivery::PRESENTATION_SCHEMA_VERSION, - 284
"session_id": "fixture-session", - 285
"cursor": null, - 286
"items": [{ - 287
"id": "fixture-job/answer", - 288
"timestamp": "", - 289
"turn_id": "fixture-job", - 290
"role": "assistant", - 291
"kind": "outcome", - 292
"status": "succeeded", - 293
"outcome": null, - 294
"content": {"type": "adaptive", "tree": { - 295
"schema_version": 1, - 296
"spec_id": "fixture.title", - 297
"revision": 1, - 298
"digest": "fixture-digest", - 299
"root": {"primitive": "title", "props": {"text": "A reusable result"}, "children": []}, - 300
"accessibility_summary": "A reusable result", - 301
"coverage": {"rendered_paths": [], "omitted_paths": []} - 302
}, "fallback_text": "A reusable result"}, - 303
"provenance": null, - 304
"actions": [], - 305
"fallback_text": "A reusable result" - 306
}], - 307
"diagnostics": [], - 308
"goal": null - 309
} - 310
} - 311
}); - 312
let packet = prepared_packet(body, surface).expect("shared adaptive fixture"); - 313
assert_eq!(packet.chunks, vec!["A reusable result"]); - 314
} - 315
} - 316
} - 317
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.