Skip to main content

brain_edge/app/handlers/
llm_proxy.rs

1//! The BYOK broker: Brain's LLM calls, re-signed with the tenant's own key.
2//!
3//! # Why this exists
4//!
5//! Brain's extraction and HyPE stages need an LLM, and under BYOK the call must
6//! bill to the tenant's provider account. The engine therefore does not hold the
7//! key — it points its provider client's `base_url` here and sends a namespace
8//! instead. This module opens that namespace's sealed credential and re-signs
9//! the request upstream.
10//!
11//! # Why the namespace is in the path
12//!
13//! Brain's provider clients build their URL as
14//! `format!("{}/v1/chat/completions", base_url)` and authenticate with
15//! `.bearer_auth(api_key)`. They cannot set a custom header. So for those
16//! clients to work **unchanged**, both routing inputs have to travel in the two
17//! channels they do control: the namespace in `base_url`, the broker token as
18//! the bearer.
19//!
20//! Pointing `base_url` at `{edge}/llm/ns/{namespace}/openai` therefore resolves
21//! to this module, and the otherwise-redundant-looking path suffix is what
22//! makes a zero-change integration possible.
23//!
24//! A namespace is a tenant identifier, not a secret, and it will appear in
25//! access logs — which is acceptable here and is why the *key* never travels
26//! this way.
27//!
28//! # Threat model — stated precisely, because it is easy to overclaim
29//!
30//! What this buys: **Brain's data at rest contains no provider keys.** Its WAL,
31//! snapshots and backups never see one, so a stolen disk or a leaked backup
32//! yields nothing. The store behind the edge holds ciphertext only, so a
33//! database compromise yields nothing either.
34//!
35//! What it does **not** buy: a fully compromised *running* Brain can ask the
36//! edge for any namespace it can name, and get completions billed to that
37//! tenant. The shared service token authenticates "a Brain", not "this
38//! namespace's Brain".
39//!
40//! **That gap is structural, not a missing feature.** It is tempting to reach
41//! for per-namespace signed grants, but they do not close it:
42//!
43//! - If Brain *requests* a grant using the shared token, a compromised Brain
44//!   requests any namespace it likes — exactly the situation we started in.
45//! - If the grant instead rides on the client's ENCODE request, it has to
46//!   survive into derivation, which runs **asynchronously after the write is
47//!   acknowledged**. Surviving that means persisting it in Brain, which is
48//!   precisely the key-at-rest this design exists to avoid.
49//!
50//! The root cause is that a single multi-tenant Brain must be able to obtain a
51//! key for every namespace it processes writes for. No token scheme removes
52//! that. What genuinely limits the blast radius is **detection and bounds**:
53//! every resolution is audit-logged below, and per-namespace spend caps belong
54//! here too. Separating the credential plane from the data plane
55//! (a deployment concern) keeps provider traffic out of the busiest process.
56//!
57//! Recorded at this length because "we'll add signed grants later" reads like a
58//! plan, and acting on it would have produced ceremony with no security gain.
59
60use axum::body::Body;
61use axum::extract::State;
62use axum::http::{HeaderMap, HeaderName, StatusCode};
63use axum::response::Response;
64
65use crate::crypto::SecretBinding;
66use crate::error::ApiError;
67use crate::state::EdgeState;
68
69/// Header carrying the shared token Brain authenticates to the broker with.
70///
71/// Accepted as an alternative to `Authorization: Bearer`, which is what Brain's
72/// own provider clients send. Both are checked so a hand-rolled caller (curl, a
73/// future service) is not forced into the bearer slot.
74pub const PROXY_TOKEN_HEADER: &str = "x-brain-edge-llm-token";
75
76/// Env var holding the broker's shared service token. Unset ⇒ the proxy is off.
77pub const PROXY_TOKEN_ENV: &str = "BRAIN_EDGE_LLM_PROXY_TOKEN";
78
79/// Upstream bases. Not configurable per request: a caller-supplied upstream
80/// would turn this into an open relay that signs arbitrary requests with a
81/// tenant's key.
82const OPENAI_BASE: &str = "https://api.openai.com";
83const ANTHROPIC_BASE: &str = "https://api.anthropic.com";
84
85/// Canonical wire name for OpenAI.
86///
87/// A named constant rather than a literal at each use: this exact string is
88/// the `provider` field of a stored credential, a segment of the broker URL,
89/// **and** part of the AEAD binding the credential is sealed under. A typo in
90/// any one place is a runtime decrypt failure, not a compile error.
91pub const PROVIDER_OPENAI: &str = "openai";
92/// Canonical wire name for Anthropic. See [`PROVIDER_OPENAI`].
93pub const PROVIDER_ANTHROPIC: &str = "anthropic";
94
95/// Path prefix the broker routes live under.
96///
97/// Kept beside [`Upstream`] because `brain-llm`'s `BrokerResolver` builds
98/// `{edge}/llm/ns/{namespace}/{provider}` to match. The two live in separate
99/// repos, so neither can reference the other's constant — a test on each side
100/// pins the shape instead.
101pub const BROKER_PATH_PREFIX: &str = "/llm/ns";
102
103/// Which upstream a route serves.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Upstream {
106    /// OpenAI's chat-completions API.
107    OpenAi,
108    /// Anthropic's messages API.
109    Anthropic,
110}
111
112impl Upstream {
113    /// Every upstream this edge can serve. The single source of truth: the
114    /// accepted-provider list and the credential validator both derive from
115    /// it, so adding a provider cannot leave one of them behind.
116    pub const ALL: [Self; 2] = [Self::OpenAi, Self::Anthropic];
117
118    /// Canonical wire name.
119    #[must_use]
120    pub const fn provider(self) -> &'static str {
121        match self {
122            Self::OpenAi => PROVIDER_OPENAI,
123            Self::Anthropic => PROVIDER_ANTHROPIC,
124        }
125    }
126
127    /// Parse a canonical provider name. The inverse of [`Self::provider`].
128    #[must_use]
129    pub fn from_name(name: &str) -> Option<Self> {
130        Self::ALL.into_iter().find(|u| u.provider() == name)
131    }
132
133    const fn base(self) -> &'static str {
134        match self {
135            Self::OpenAi => OPENAI_BASE,
136            Self::Anthropic => ANTHROPIC_BASE,
137        }
138    }
139}
140
141/// Compare the presented token against the configured one in constant time.
142///
143/// A byte-wise `==` short-circuits on the first differing byte, leaking the
144/// length of the shared prefix through timing — enough to recover a token given
145/// enough attempts.
146///
147/// `subtle`, not `ring::constant_time::verify_slices_are_equal`: the latter is
148/// deprecated and documents "no promises regarding side channels", which is the
149/// single promise this function needs.
150fn token_matches(presented: &str, configured: &str) -> bool {
151    use subtle::ConstantTimeEq;
152    // Length is compared non-secretly: a constant-time compare needs equal
153    // lengths, and the length of a shared deployment token is not the secret.
154    presented.len() == configured.len()
155        && bool::from(presented.as_bytes().ct_eq(configured.as_bytes()))
156}
157
158/// Authenticate the caller against the configured broker token.
159fn authenticate(headers: &HeaderMap) -> Result<(), ApiError> {
160    authenticate_with(std::env::var(PROXY_TOKEN_ENV).ok().as_deref(), headers)
161}
162
163/// The token the caller presented, from either accepted channel.
164fn presented_token(headers: &HeaderMap) -> &str {
165    if let Some(v) = headers
166        .get(HeaderName::from_static(PROXY_TOKEN_HEADER))
167        .and_then(|v| v.to_str().ok())
168    {
169        return v.trim();
170    }
171    headers
172        .get(axum::http::header::AUTHORIZATION)
173        .and_then(|v| v.to_str().ok())
174        .map(str::trim)
175        .and_then(|v| {
176            v.strip_prefix("Bearer ")
177                .or_else(|| v.strip_prefix("bearer "))
178        })
179        .map(str::trim)
180        .unwrap_or_default()
181}
182
183/// The testable core of [`authenticate`]: the configured token is a parameter
184/// rather than an environment read, so the auth rules can be tested without
185/// mutating process state (which this crate forbids — `unsafe` is denied, and
186/// `set_var` is unsafe in edition 2024).
187fn authenticate_with(configured: Option<&str>, headers: &HeaderMap) -> Result<(), ApiError> {
188    let Some(configured) = configured.map(str::trim).filter(|s| !s.is_empty()) else {
189        return Err(ApiError::not_implemented(
190            "the BYOK LLM broker is not enabled on this edge",
191        ));
192    };
193    if !token_matches(presented_token(headers), configured) {
194        // Deliberately identical to the missing-token case: distinguishing
195        // them would confirm that a token was *shaped* right.
196        return Err(ApiError::unauthorized("broker token missing or invalid"));
197    }
198    Ok(())
199}
200
201/// `POST /llm/openai/v1/chat/completions`
202///
203/// # Errors
204/// See [`proxy`].
205pub async fn openai_chat_completions(
206    State(state): State<EdgeState>,
207    axum::extract::Path(namespace): axum::extract::Path<String>,
208    headers: HeaderMap,
209    body: axum::body::Bytes,
210) -> Result<Response, ApiError> {
211    proxy(
212        &state,
213        &headers,
214        &namespace,
215        Upstream::OpenAi,
216        "/v1/chat/completions",
217        body,
218    )
219    .await
220}
221
222/// `POST /llm/anthropic/v1/messages`
223///
224/// # Errors
225/// See [`proxy`].
226pub async fn anthropic_messages(
227    State(state): State<EdgeState>,
228    axum::extract::Path(namespace): axum::extract::Path<String>,
229    headers: HeaderMap,
230    body: axum::body::Bytes,
231) -> Result<Response, ApiError> {
232    proxy(
233        &state,
234        &headers,
235        &namespace,
236        Upstream::Anthropic,
237        "/v1/messages",
238        body,
239    )
240    .await
241}
242
243/// `GET /llm/ns/{namespace}/resolve` — what credential serves this namespace.
244///
245/// Brain's resolver calls this to learn which provider and model to build a
246/// client for, and to find out whether a credential exists at all. It returns
247/// **no key material** — only what Brain needs to route, which is why Brain can
248/// hold the answer in memory without that being a key at rest.
249///
250/// # Errors
251/// `401` on a bad broker token, `409 llm_credential.missing` when the namespace
252/// has no key, `501` when BYOK or the broker is unconfigured.
253pub async fn resolve(
254    State(state): State<EdgeState>,
255    axum::extract::Path(namespace): axum::extract::Path<String>,
256    headers: HeaderMap,
257) -> Result<axum::response::Json<crate::dto::llm_credential::ResolvedLlmDto>, ApiError> {
258    authenticate(&headers)?;
259    let store = state.llm_credentials().ok_or_else(|| {
260        ApiError::not_implemented("BYOK is not configured on this edge: no key-encryption key")
261    })?;
262    let record = store.get(&namespace).await?.ok_or_else(|| {
263        ApiError::llm_credential_missing(format!(
264            "no LLM provider key is registered for namespace {namespace}"
265        ))
266    })?;
267    tracing::info!(
268        target: "brain_edge::llm_audit",
269        namespace = %namespace,
270        provider = %record.provider,
271        "BYOK: credential resolved for a namespace",
272    );
273    Ok(axum::response::Json(
274        crate::dto::llm_credential::ResolvedLlmDto {
275            provider: record.provider,
276            model: record.model,
277        },
278    ))
279}
280
281/// Open the namespace's key and re-sign the request upstream.
282///
283/// # Errors
284/// `401` on a bad broker token, `409` when the tenant's registered provider is
285/// not the one this route serves, `501` when BYOK or the broker is
286/// unconfigured, `502` when the upstream cannot be reached. A missing
287/// credential is `409 llm_credential.missing`, matching what the data plane
288/// returns for the same condition so one code means one thing everywhere.
289#[allow(clippy::too_many_arguments)]
290async fn proxy(
291    state: &EdgeState,
292    headers: &HeaderMap,
293    namespace: &str,
294    upstream: Upstream,
295    path: &str,
296    body: axum::body::Bytes,
297) -> Result<Response, ApiError> {
298    authenticate(headers)?;
299
300    let store = state.llm_credentials().ok_or_else(|| {
301        ApiError::not_implemented("BYOK is not configured on this edge: no key-encryption key")
302    })?;
303    let record = store.get(namespace).await?.ok_or_else(|| {
304        ApiError::llm_credential_missing(format!(
305            "no LLM provider key is registered for namespace {namespace}"
306        ))
307    })?;
308
309    // A tenant registered for one provider must not have their key replayed
310    // against another: the AAD would reject it anyway, but failing here gives
311    // an actionable error instead of an opaque decrypt failure.
312    if record.provider != upstream.provider() {
313        return Err(ApiError::conflict(format!(
314            "namespace {namespace} is registered for `{}`, not `{}`",
315            record.provider,
316            upstream.provider()
317        )));
318    }
319
320    let key = state.open_llm_key(
321        &record.sealed,
322        SecretBinding {
323            namespace,
324            provider: upstream.provider(),
325        },
326    )?;
327    let key = std::str::from_utf8(&key)
328        .map_err(|_| ApiError::internal("stored provider key is not valid UTF-8"))?;
329
330    // Audit trail. Since a compromised Brain can name any namespace (see the
331    // module docs), detection is the control that actually applies — an
332    // unexpected namespace appearing here is the signal. Logged before the
333    // upstream call so an attempt is recorded even if the call then fails.
334    tracing::info!(
335        target: "brain_edge::llm_audit",
336        namespace = %namespace,
337        provider = upstream.provider(),
338        fingerprint = %record.fingerprint,
339        "BYOK: provider key used for an upstream call",
340    );
341
342    let url = format!("{}{path}", upstream.base());
343    let client = reqwest::Client::new();
344    let mut req = client
345        .post(&url)
346        .header("content-type", "application/json")
347        .body(body.to_vec());
348
349    // Each provider authenticates differently. The tenant's key is attached
350    // here and nowhere else; the inbound request never carried it.
351    req = match upstream {
352        Upstream::OpenAi => req.header("authorization", format!("Bearer {key}")),
353        Upstream::Anthropic => req
354            .header("x-api-key", key)
355            .header("anthropic-version", anthropic_version(headers)),
356    };
357
358    let resp = req.send().await.map_err(|e| {
359        // The error is logged, not returned: a reqwest error can embed the URL
360        // and headers, and these headers carry the tenant's key.
361        tracing::warn!(
362            namespace = %namespace,
363            provider = upstream.provider(),
364            error = %e,
365            "LLM upstream request failed",
366        );
367        ApiError::bad_gateway("LLM provider request failed")
368    })?;
369
370    let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
371    let content_type = resp
372        .headers()
373        .get(reqwest::header::CONTENT_TYPE)
374        .and_then(|v| v.to_str().ok())
375        .unwrap_or("application/json")
376        .to_string();
377
378    // Stream the body through rather than buffering: a streamed completion is
379    // the common case and buffering it would both add latency and let one
380    // large response pin memory.
381    let stream = resp.bytes_stream();
382    let mut out = Response::builder()
383        .status(status)
384        .header("content-type", content_type);
385    // Only the status and content type are forwarded. Upstream headers are NOT
386    // echoed wholesale: they can carry provider-side rate-limit and
387    // organisation identifiers belonging to the tenant's account.
388    if status.is_success() {
389        out = out.header("x-brain-llm-provider", upstream.provider());
390    }
391    out.body(Body::from_stream(stream))
392        .map_err(|e| ApiError::internal(format!("build proxy response: {e}")))
393}
394
395/// Anthropic requires an API version header. Pass the caller's through when
396/// present so Brain controls it, with a pinned default otherwise.
397fn anthropic_version(headers: &HeaderMap) -> String {
398    headers
399        .get("anthropic-version")
400        .and_then(|v| v.to_str().ok())
401        .map(str::trim)
402        .filter(|s| !s.is_empty())
403        .unwrap_or("2023-06-01")
404        .to_string()
405}
406
407/// Outcome of probing a provider key before storing it.
408///
409/// The three-way split mirrors the credential-resolution one, for the same
410/// reason: "the provider says this key is bad" and "I could not reach the
411/// provider" demand opposite responses, and collapsing them would either
412/// accept dead keys during an outage or refuse good ones.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub enum KeyCheck {
415    /// The provider accepted the key.
416    Valid,
417    /// The provider rejected it — a typo or a revoked key.
418    Rejected(String),
419    /// The provider could not be reached, or answered something unexpected.
420    Unreachable(String),
421}
422
423/// Classify a probe response status.
424///
425/// Only `401`/`403` are treated as "this key is bad". A `429` or `5xx` says
426/// nothing about the key — rejecting on those would make key registration fail
427/// whenever the provider is rate-limiting or having an incident.
428fn classify_probe(status: u16) -> KeyCheck {
429    match status {
430        200..=299 => KeyCheck::Valid,
431        401 | 403 => KeyCheck::Rejected(format!("the provider rejected this key ({status})")),
432        other => KeyCheck::Unreachable(format!("provider returned {other} while verifying")),
433    }
434}
435
436/// Probe a provider key with a cheap read-only call before storing it.
437///
438/// Without this a typo'd key is accepted happily and then fails on every write
439/// with an opaque upstream `401` that looks like a Brain bug. Catching it at
440/// registration puts the error where the mistake was made.
441///
442/// `GET /v1/models` on both providers: read-only, no tokens billed, and it
443/// exercises exactly the credential path a real call would.
444pub async fn validate_key(upstream: Upstream, key: &str) -> KeyCheck {
445    let url = format!("{}/v1/models", upstream.base());
446    let client = reqwest::Client::new();
447    let req = match upstream {
448        Upstream::OpenAi => client.get(&url).bearer_auth(key),
449        Upstream::Anthropic => client
450            .get(&url)
451            .header("x-api-key", key)
452            .header("anthropic-version", "2023-06-01"),
453    };
454
455    match req.send().await {
456        Ok(resp) => classify_probe(resp.status().as_u16()),
457        Err(e) => {
458            // Logged, not returned: a reqwest error can embed the request
459            // headers, which carry the key being verified.
460            tracing::warn!(
461                target: "brain_edge::llm_proxy",
462                provider = upstream.provider(),
463                error = %e,
464                "could not reach provider to verify key",
465            );
466            KeyCheck::Unreachable("could not reach the provider to verify this key".into())
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn token_comparison_rejects_wrong_and_accepts_right() {
477        assert!(token_matches("s3cret", "s3cret"));
478        assert!(!token_matches("s3cret", "s3creT"));
479        assert!(!token_matches("", "s3cret"));
480        assert!(!token_matches("s3cret", ""));
481        assert!(!token_matches("s3cret-longer", "s3cret"));
482    }
483
484    #[test]
485    fn upstream_bases_are_fixed() {
486        // A caller-supplied upstream would make this an open relay signing
487        // arbitrary requests with a tenant's key.
488        assert_eq!(Upstream::OpenAi.base(), "https://api.openai.com");
489        assert_eq!(Upstream::Anthropic.base(), "https://api.anthropic.com");
490    }
491
492    #[test]
493    fn provider_names_round_trip() {
494        // The name is the stored `provider` field AND part of the sealing
495        // AAD, so the two directions disagreeing means credentials stop
496        // opening.
497        for u in Upstream::ALL {
498            assert_eq!(Upstream::from_name(u.provider()), Some(u));
499        }
500        assert_eq!(Upstream::from_name("cohere"), None);
501    }
502
503    #[test]
504    fn the_broker_path_matches_what_brain_builds() {
505        // `brain-llm`'s BrokerResolver constructs
506        // `{edge}/llm/ns/{namespace}/{provider}` and appends the provider's
507        // own suffix. Separate repos, so this pins our half of the contract.
508        assert_eq!(BROKER_PATH_PREFIX, "/llm/ns");
509        assert_eq!(
510            format!(
511                "{BROKER_PATH_PREFIX}/acme/{}/v1/chat/completions",
512                Upstream::OpenAi.provider()
513            ),
514            "/llm/ns/acme/openai/v1/chat/completions"
515        );
516        assert_eq!(
517            format!(
518                "{BROKER_PATH_PREFIX}/acme/{}/v1/messages",
519                Upstream::Anthropic.provider()
520            ),
521            "/llm/ns/acme/anthropic/v1/messages"
522        );
523    }
524
525    #[test]
526    fn anthropic_version_passes_through_or_defaults() {
527        let mut h = HeaderMap::new();
528        assert_eq!(anthropic_version(&h), "2023-06-01");
529        h.insert("anthropic-version", "2024-10-22".parse().unwrap());
530        assert_eq!(anthropic_version(&h), "2024-10-22");
531        h.insert("anthropic-version", "  ".parse().unwrap());
532        assert_eq!(anthropic_version(&h), "2023-06-01", "blank falls back");
533    }
534
535    fn headers_with(token: &str) -> HeaderMap {
536        let mut h = HeaderMap::new();
537        if !token.is_empty() {
538            h.insert(PROXY_TOKEN_HEADER, token.parse().unwrap());
539        }
540        h
541    }
542
543    fn bearer_headers(token: &str) -> HeaderMap {
544        let mut h = HeaderMap::new();
545        h.insert(
546            axum::http::header::AUTHORIZATION,
547            format!("Bearer {token}").parse().unwrap(),
548        );
549        h
550    }
551
552    #[test]
553    fn an_unconfigured_broker_is_501_not_401() {
554        // The deployment has not turned the broker on, which is a different
555        // problem from a bad credential — and a 401 would send an operator
556        // hunting for a wrong token that does not exist.
557        for configured in [None, Some(""), Some("   ")] {
558            let err = authenticate_with(configured, &headers_with("t")).unwrap_err();
559            assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
560        }
561    }
562
563    #[test]
564    fn a_wrong_or_missing_token_is_rejected_identically() {
565        // Same status and message either way: distinguishing them confirms a
566        // token was shaped right, which is a probing aid.
567        let missing = authenticate_with(Some("real"), &headers_with("")).unwrap_err();
568        let wrong = authenticate_with(Some("real"), &headers_with("nope")).unwrap_err();
569        assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
570        assert_eq!(wrong.status(), StatusCode::UNAUTHORIZED);
571        assert_eq!(missing.message(), wrong.message());
572    }
573
574    #[test]
575    fn a_valid_token_authenticates_via_either_channel() {
576        // Brain's provider clients can only send a bearer; a hand-rolled caller
577        // may prefer the explicit header. Both must work.
578        assert!(authenticate_with(Some("real"), &headers_with("real")).is_ok());
579        assert!(authenticate_with(Some("real"), &bearer_headers("real")).is_ok());
580    }
581
582    #[test]
583    fn the_explicit_header_wins_over_a_bearer() {
584        // Deterministic precedence: a caller sending both should not get a
585        // result that depends on header iteration order.
586        let mut h = bearer_headers("real");
587        h.insert(PROXY_TOKEN_HEADER, "wrong".parse().unwrap());
588        assert!(authenticate_with(Some("real"), &h).is_err());
589    }
590
591    #[test]
592    fn a_bearer_without_the_prefix_is_not_accepted() {
593        let mut h = HeaderMap::new();
594        h.insert(axum::http::header::AUTHORIZATION, "real".parse().unwrap());
595        assert!(authenticate_with(Some("real"), &h).is_err());
596    }
597}
598
599#[cfg(test)]
600mod key_check_tests {
601    use super::*;
602
603    #[test]
604    fn only_auth_statuses_mean_the_key_is_bad() {
605        assert_eq!(classify_probe(200), KeyCheck::Valid);
606        assert_eq!(classify_probe(204), KeyCheck::Valid);
607        assert!(matches!(classify_probe(401), KeyCheck::Rejected(_)));
608        assert!(matches!(classify_probe(403), KeyCheck::Rejected(_)));
609    }
610
611    #[test]
612    fn transient_statuses_do_not_condemn_the_key() {
613        // Rejecting on these would make key registration fail whenever the
614        // provider is rate-limiting or having an incident — the user would
615        // conclude their perfectly good key is invalid.
616        for status in [429, 500, 502, 503, 504] {
617            assert!(
618                matches!(classify_probe(status), KeyCheck::Unreachable(_)),
619                "{status} says nothing about the key"
620            );
621        }
622    }
623
624    #[test]
625    fn an_unexpected_status_is_unreachable_not_rejected() {
626        // Fail open on the unknown: a 404 means the probe endpoint moved, not
627        // that the operator's key is wrong.
628        assert!(matches!(classify_probe(404), KeyCheck::Unreachable(_)));
629        assert!(matches!(classify_probe(418), KeyCheck::Unreachable(_)));
630    }
631
632    #[test]
633    fn no_check_message_can_contain_a_key() {
634        // These strings are returned to the caller and logged.
635        for c in [
636            classify_probe(401),
637            classify_probe(500),
638            classify_probe(200),
639        ] {
640            let text = format!("{c:?}");
641            assert!(!text.contains("sk-"), "{text}");
642        }
643    }
644}