Skip to main content

systemprompt_api/routes/gateway/
bridge_error.rs

1//! Typed failures of the bridge routes, their single mapping onto the unified
2//! `ApiError` envelope, and the bridge credential check the routes share.
3//!
4//! A rejected credential answers 401 with a fixed message; the decoder's cause
5//! travels as the error source, so it reaches the log and never the client.
6//! Server-side failures carry a static context and their cause, and leave
7//! through `ApiError`'s fixed 5xx text.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use axum::extract::rejection::JsonRejection;
13use axum::http::HeaderMap;
14use systemprompt_identifiers::{JwtToken, ManagedResourceId, UserId};
15use systemprompt_marketplace::managed::ManagedError;
16use systemprompt_models::api::ApiError;
17use systemprompt_models::bridge::host::HostKind;
18use systemprompt_models::execution::ContextExtractionError;
19use systemprompt_oauth::OauthError;
20use systemprompt_traits::{AuthUser, BoxedSource};
21use systemprompt_users::UserError;
22
23use super::messages::extract_credential;
24use crate::error::ApiHttpError;
25use crate::services::middleware::JwtContextExtractor;
26use crate::services::middleware::jwt::JwtUserContext;
27
28#[derive(Debug, thiserror::Error)]
29pub enum BridgeError {
30    #[error("Missing Authorization or x-api-key credential")]
31    MissingCredential,
32    #[error("Bridge credential rejected")]
33    CredentialRejected(#[source] ContextExtractionError),
34    #[error("heartbeat session_id must match the authenticated session")]
35    SessionMismatch,
36    #[error("invalid request body: {0}")]
37    InvalidBody(#[source] JsonRejection),
38    #[error("unknown API surface: {0}")]
39    UnknownSurface(String),
40    #[error("host '{0}' is disabled on this installation")]
41    HostDisabled(HostKind),
42    #[error("Gateway not enabled")]
43    GatewayDisabled,
44    #[error("User not found: {0}")]
45    UserNotFound(UserId),
46    #[error("Invalid path")]
47    InvalidPath,
48    #[error("Plugin not found")]
49    PluginNotFound,
50    #[error("File not found")]
51    FileNotFound,
52    #[error("{0}")]
53    DeviceForeignUser(String),
54    #[error("{0}")]
55    DeviceRejected(String),
56    #[error("manifest: catalogue grant not recorded for {resource}")]
57    CatalogGrant {
58        resource: ManagedResourceId,
59        #[source]
60        source: ManagedError,
61    },
62    #[error("{context}")]
63    Unavailable {
64        context: &'static str,
65        #[source]
66        source: BoxedSource,
67    },
68    #[error("{context}")]
69    Internal {
70        context: &'static str,
71        #[source]
72        source: BoxedSource,
73    },
74    #[error(transparent)]
75    Users(#[from] UserError),
76    #[error(transparent)]
77    Oauth(#[from] OauthError),
78}
79
80impl BridgeError {
81    pub fn internal(context: &'static str, source: impl Into<BoxedSource>) -> Self {
82        Self::Internal {
83            context,
84            source: source.into(),
85        }
86    }
87
88    pub fn unavailable(context: &'static str, source: impl Into<BoxedSource>) -> Self {
89        Self::Unavailable {
90            context,
91            source: source.into(),
92        }
93    }
94}
95
96impl From<BridgeError> for ApiHttpError {
97    fn from(err: BridgeError) -> Self {
98        let message = err.to_string();
99        let api = match err {
100            BridgeError::MissingCredential => {
101                ApiError::unauthorized(message).with_error_key("missing_credential")
102            },
103            BridgeError::CredentialRejected(source) => ApiError::unauthorized(message)
104                .with_error_key("invalid_credential")
105                .with_source(source),
106            BridgeError::SessionMismatch => {
107                ApiError::unauthorized(message).with_error_key("session_mismatch")
108            },
109            BridgeError::InvalidBody(_) => {
110                ApiError::bad_request(message).with_error_key("invalid_body")
111            },
112            BridgeError::UnknownSurface(_) => {
113                ApiError::bad_request(message).with_error_key("unknown_api_surface")
114            },
115            BridgeError::HostDisabled(_) => {
116                ApiError::validation_error(message, Vec::new()).with_error_key("host_disabled")
117            },
118            BridgeError::GatewayDisabled => {
119                ApiError::not_found(message).with_error_key("gateway_disabled")
120            },
121            BridgeError::UserNotFound(_)
122            | BridgeError::PluginNotFound
123            | BridgeError::FileNotFound => ApiError::not_found(message),
124            BridgeError::InvalidPath | BridgeError::DeviceRejected(_) => {
125                ApiError::bad_request(message)
126            },
127            BridgeError::DeviceForeignUser(_) => {
128                ApiError::conflict(message).with_error_key("device_fingerprint_foreign_user")
129            },
130            err @ BridgeError::CatalogGrant { .. } => {
131                ApiError::internal("manifest: catalogue grant not recorded", err)
132            },
133            BridgeError::Unavailable { context, source } => {
134                ApiError::service_unavailable(context).with_source(source)
135            },
136            BridgeError::Internal { context, source } => ApiError::internal(context, source),
137            BridgeError::Users(inner) => return Self::from(inner),
138            BridgeError::Oauth(inner) => return Self::from(inner),
139        };
140        Self::from(api)
141    }
142}
143
144pub async fn authenticate_bridge(
145    jwt_extractor: &JwtContextExtractor,
146    headers: &HeaderMap,
147) -> Result<(JwtUserContext, AuthUser), BridgeError> {
148    let credential = extract_credential(headers).ok_or(BridgeError::MissingCredential)?;
149    jwt_extractor
150        .decode_for_gateway(&JwtToken::new(credential))
151        .await
152        .map_err(BridgeError::CredentialRejected)
153}