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("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}