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