An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1use serde::Serialize;
2use serde_json::Value;
3
4/// Error codes for the provisioning API.
5///
6/// Most variants serialize as SCREAMING_SNAKE_CASE. Exceptions use `#[serde(rename)]`
7/// when a specific wire format is required (e.g. `MethodNotImplemented` uses PascalCase
8/// to match the AT Protocol XRPC error format).
9///
10/// `#[non_exhaustive]` prevents external crates from writing exhaustive match
11/// arms — new variants can be added in future waves without breaking callers.
12#[non_exhaustive]
13#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum ErrorCode {
16 InvalidClaim,
17 Unauthorized,
18 TokenExpired,
19 Forbidden,
20 NotFound,
21 WeakPassword,
22 RateLimited,
23 ExportInProgress,
24 ServiceUnavailable,
25 InternalError,
26 /// Returned for any XRPC NSID that has no registered handler.
27 ///
28 /// Serialized as `"MethodNotImplemented"` (PascalCase) to match the AT Protocol XRPC
29 /// error format, which uses PascalCase error names rather than SCREAMING_SNAKE_CASE.
30 #[serde(rename = "MethodNotImplemented")]
31 MethodNotImplemented,
32 /// An account with the given email already exists (pending or active).
33 AccountExists,
34 /// The requested handle is already claimed by an active or pending account.
35 HandleTaken,
36 /// The handle string failed basic format validation.
37 InvalidHandle,
38 /// A claim code that has already been redeemed is presented again.
39 /// Clients should inform the user to obtain a different code.
40 ClaimCodeRedeemed,
41 /// The DID has already been fully promoted to an active account.
42 DidAlreadyExists,
43 /// The external PLC directory returned a non-success response.
44 PlcDirectoryError,
45 /// A configured DNS provider returned an error when creating a subdomain record.
46 DnsError,
47 /// The requested handle does not resolve to a known DID locally or via DNS.
48 HandleNotFound,
49 /// Missing or absent Authorization header on a protected endpoint.
50 AuthenticationRequired,
51 /// Token is structurally invalid, has wrong signature, wrong audience, or DPoP mismatch.
52 InvalidToken,
53 // TODO: add remaining codes from Appendix A as endpoints are implemented:
54 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION
55 // 401: INVALID_CREDENTIALS
56 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER
57 // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, NOT_IN_GRACE_PERIOD
58 // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS,
59 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION
60 // 410: ALREADY_DELETED
61 // 422: INVALID_KEY, KEY_MISMATCH, DIDWEB_SELF_SERVICE
62 // 423: ACCOUNT_LOCKED
63}
64
65impl ErrorCode {
66 /// Returns the canonical HTTP status code for this error as a `u16`.
67 pub fn status_code(&self) -> u16 {
68 match self {
69 ErrorCode::InvalidClaim => 400,
70 ErrorCode::Unauthorized => 401,
71 ErrorCode::TokenExpired => 401,
72 ErrorCode::Forbidden => 403,
73 ErrorCode::NotFound => 404,
74 ErrorCode::WeakPassword => 422,
75 ErrorCode::RateLimited => 429,
76 ErrorCode::ExportInProgress => 503,
77 ErrorCode::ServiceUnavailable => 503,
78 ErrorCode::InternalError => 500,
79 ErrorCode::MethodNotImplemented => 501,
80 ErrorCode::AccountExists => 409,
81 ErrorCode::HandleTaken => 409,
82 ErrorCode::InvalidHandle => 400,
83 ErrorCode::ClaimCodeRedeemed => 409,
84 ErrorCode::DidAlreadyExists => 409,
85 ErrorCode::PlcDirectoryError => 502,
86 ErrorCode::DnsError => 502,
87 ErrorCode::HandleNotFound => 404,
88 ErrorCode::AuthenticationRequired => 401,
89 ErrorCode::InvalidToken => 401,
90 }
91 }
92}
93
94/// Provisioning API error, serialized as the standard error envelope.
95///
96/// Without details:
97/// ```json
98/// { "error": { "code": "NOT_FOUND", "message": "..." } }
99/// ```
100///
101/// With details:
102/// ```json
103/// { "error": { "code": "INVALID_CLAIM", "message": "...", "details": { "field": "email" } } }
104/// ```
105///
106/// Implements `IntoResponse` for Axum when the `axum` feature is enabled.
107#[derive(Debug, Serialize, thiserror::Error)]
108#[error("{code:?}: {message}")]
109pub struct ApiError {
110 code: ErrorCode,
111 message: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 details: Option<Value>,
114}
115
116impl ApiError {
117 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
118 Self {
119 code,
120 message: message.into(),
121 details: None,
122 }
123 }
124
125 pub fn with_details(mut self, details: Value) -> Self {
126 self.details = Some(details);
127 self
128 }
129
130 /// Returns the HTTP status code for this error as a `u16`.
131 pub fn status_code(&self) -> u16 {
132 self.code.status_code()
133 }
134}
135
136/// Wraps `ApiError` in the `{ "error": ... }` envelope for serialization.
137#[cfg(any(feature = "axum", test))]
138#[derive(Serialize)]
139struct ApiErrorEnvelope {
140 error: ApiError,
141}
142
143#[cfg(feature = "axum")]
144mod axum_integration {
145 use super::*;
146 use axum::{
147 http::{header, StatusCode},
148 response::{IntoResponse, Response},
149 Json,
150 };
151
152 impl IntoResponse for ApiError {
153 fn into_response(self) -> Response {
154 let status = StatusCode::from_u16(self.code.status_code())
155 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
156
157 match serde_json::to_vec(&ApiErrorEnvelope { error: self }) {
158 Ok(body) => {
159 (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
160 }
161 Err(err) => {
162 tracing::error!(error = %err, "failed to serialize ApiError");
163 (
164 StatusCode::INTERNAL_SERVER_ERROR,
165 Json(serde_json::json!({
166 "error": {
167 "code": "INTERNAL_SERVER_ERROR",
168 "message": "internal error"
169 }
170 })),
171 )
172 .into_response()
173 }
174 }
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use serde_json::json;
183
184 #[test]
185 fn serializes_to_error_envelope() {
186 let err = ApiError::new(ErrorCode::NotFound, "resource not found");
187 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
188 assert_eq!(
189 actual,
190 json!({
191 "error": {
192 "code": "NOT_FOUND",
193 "message": "resource not found"
194 }
195 })
196 );
197 }
198
199 #[test]
200 fn serializes_with_details() {
201 let err = ApiError::new(ErrorCode::InvalidClaim, "validation failed")
202 .with_details(json!({ "field": "email" }));
203 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
204 assert_eq!(
205 actual,
206 json!({
207 "error": {
208 "code": "INVALID_CLAIM",
209 "message": "validation failed",
210 "details": { "field": "email" }
211 }
212 })
213 );
214 }
215
216 #[test]
217 fn omits_details_when_absent() {
218 let err = ApiError::new(ErrorCode::Forbidden, "access denied");
219 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
220 assert!(!actual["error"].as_object().unwrap().contains_key("details"));
221 }
222
223 #[test]
224 fn status_code_mapping() {
225 let cases = [
226 (ErrorCode::InvalidClaim, 400u16),
227 (ErrorCode::Unauthorized, 401),
228 (ErrorCode::TokenExpired, 401),
229 (ErrorCode::Forbidden, 403),
230 (ErrorCode::NotFound, 404),
231 (ErrorCode::WeakPassword, 422),
232 (ErrorCode::RateLimited, 429),
233 (ErrorCode::ExportInProgress, 503),
234 (ErrorCode::ServiceUnavailable, 503),
235 (ErrorCode::InternalError, 500),
236 (ErrorCode::MethodNotImplemented, 501),
237 (ErrorCode::AccountExists, 409),
238 (ErrorCode::HandleTaken, 409),
239 (ErrorCode::InvalidHandle, 400),
240 (ErrorCode::ClaimCodeRedeemed, 409),
241 (ErrorCode::DidAlreadyExists, 409),
242 (ErrorCode::PlcDirectoryError, 502),
243 (ErrorCode::DnsError, 502),
244 (ErrorCode::HandleNotFound, 404),
245 (ErrorCode::AuthenticationRequired, 401),
246 (ErrorCode::InvalidToken, 401),
247 ];
248 for (code, expected) in cases {
249 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
250 }
251 }
252
253 #[cfg(feature = "axum")]
254 mod axum_tests {
255 use super::*;
256 use axum::http::StatusCode;
257 use axum::response::IntoResponse;
258
259 #[tokio::test]
260 async fn into_response_correct_status_and_body() {
261 let err = ApiError::new(ErrorCode::NotFound, "not found");
262 let response = err.into_response();
263 assert_eq!(response.status(), StatusCode::NOT_FOUND);
264 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
265 .await
266 .unwrap();
267 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
268 assert_eq!(json["error"]["code"], "NOT_FOUND");
269 assert_eq!(json["error"]["message"], "not found");
270 }
271 }
272}