systemprompt_api/routes/gateway/
bridge_error.rs1use 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}