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("host '{0}' is the last enabled host; at least one must stay enabled")]
43    LastHostDisabled(HostKind),
44    #[error("Gateway not enabled")]
45    GatewayDisabled,
46    #[error("User not found: {0}")]
47    UserNotFound(UserId),
48    #[error("Invalid path")]
49    InvalidPath,
50    #[error("Plugin not found")]
51    PluginNotFound,
52    #[error("File not found")]
53    FileNotFound,
54    #[error("Plugin archive is not the current revision")]
55    StaleArchive,
56    #[error("{0}")]
57    DeviceForeignUser(String),
58    #[error("{0}")]
59    DeviceRejected(String),
60    #[error("manifest: catalogue grant not recorded for {resource}")]
61    CatalogGrant {
62        resource: ManagedResourceId,
63        #[source]
64        source: ManagedError,
65    },
66    #[error("manifest: {count} catalogue grants not recorded")]
67    CatalogGrants {
68        count: usize,
69        #[source]
70        source: ManagedError,
71    },
72    #[error("{context}")]
73    Unavailable {
74        context: &'static str,
75        #[source]
76        source: BoxedSource,
77    },
78    #[error("{context}")]
79    Internal {
80        context: &'static str,
81        #[source]
82        source: BoxedSource,
83    },
84    #[error(transparent)]
85    Users(#[from] UserError),
86    #[error(transparent)]
87    Oauth(#[from] OauthError),
88}
89
90impl BridgeError {
91    pub fn internal(context: &'static str, source: impl Into<BoxedSource>) -> Self {
92        Self::Internal {
93            context,
94            source: source.into(),
95        }
96    }
97
98    pub fn unavailable(context: &'static str, source: impl Into<BoxedSource>) -> Self {
99        Self::Unavailable {
100            context,
101            source: source.into(),
102        }
103    }
104}
105
106impl From<BridgeError> for ApiHttpError {
107    fn from(err: BridgeError) -> Self {
108        let message = err.to_string();
109        let api = match err {
110            BridgeError::MissingCredential => {
111                ApiError::unauthorized(message).with_error_key("missing_credential")
112            },
113            BridgeError::CredentialRejected(source) => ApiError::unauthorized(message)
114                .with_error_key("invalid_credential")
115                .with_source(source),
116            BridgeError::SessionMismatch => {
117                ApiError::unauthorized(message).with_error_key("session_mismatch")
118            },
119            BridgeError::InvalidBody(_) => {
120                ApiError::bad_request(message).with_error_key("invalid_body")
121            },
122            BridgeError::UnknownSurface(_) => {
123                ApiError::bad_request(message).with_error_key("unknown_api_surface")
124            },
125            BridgeError::HostDisabled(_) => {
126                ApiError::validation_error(message, Vec::new()).with_error_key("host_disabled")
127            },
128            BridgeError::LastHostDisabled(_) => {
129                ApiError::conflict(message).with_error_key("last_host_disabled")
130            },
131            BridgeError::GatewayDisabled => {
132                ApiError::not_found(message).with_error_key("gateway_disabled")
133            },
134            BridgeError::UserNotFound(_)
135            | BridgeError::PluginNotFound
136            | BridgeError::FileNotFound => ApiError::not_found(message),
137            BridgeError::InvalidPath | BridgeError::DeviceRejected(_) => {
138                ApiError::bad_request(message)
139            },
140            BridgeError::StaleArchive => {
141                ApiError::conflict(message).with_error_key("stale_plugin_archive")
142            },
143            BridgeError::DeviceForeignUser(_) => {
144                ApiError::conflict(message).with_error_key("device_fingerprint_foreign_user")
145            },
146            err @ (BridgeError::CatalogGrant { .. } | BridgeError::CatalogGrants { .. }) => {
147                ApiError::internal("manifest: catalogue grant not recorded", err)
148            },
149            BridgeError::Unavailable { context, source } => {
150                ApiError::service_unavailable(context).with_source(source)
151            },
152            BridgeError::Internal { context, source } => ApiError::internal(context, source),
153            BridgeError::Users(inner) => return Self::from(inner),
154            BridgeError::Oauth(inner) => return Self::from(inner),
155        };
156        Self::from(api)
157    }
158}
159
160pub async fn authenticate_bridge(
161    jwt_extractor: &JwtContextExtractor,
162    headers: &HeaderMap,
163) -> Result<(JwtUserContext, AuthUser), BridgeError> {
164    let credential = extract_credential(headers).ok_or(BridgeError::MissingCredential)?;
165    jwt_extractor
166        .decode_for_gateway(&JwtToken::new(credential))
167        .await
168        .map_err(BridgeError::CredentialRejected)
169}