-2
.gitignore
-2
.gitignore
+1
AGENTS.md
+1
AGENTS.md
···
···
1
+
CLAUDE.md
+269
CLAUDE.md
+269
CLAUDE.md
···
···
1
+
> with thanks to https://github.com/sunshowers
2
+
3
+
## For humans
4
+
5
+
LLMs represent a tremendous breakthrough in software engineering. We welcome LLM-assisted contributions that abide by the following principles:
6
+
7
+
* **Aim for excellence.** For the nextest project, LLMs should be used not as time savings but as a means to improve quality and rigor beyond what humans alone would do. Write tests that cover more edge cases. Refactor code to make it easier to understand. Tackle the TODOs. Do all the tedious things. Aim for your code to have zero bugs.
8
+
* **Spend the time savings reviewing LLM output.** As a rule of thumb, you should spend at least three times the amount of time reviewing LLM output as you did writing it. Think about every line and every design decision. Find ways to break code.
9
+
* **Your code is your responsibility.** Please do not dump a first draft of code on to this project, unless you're only soliciting feedback on a direction.
10
+
11
+
If your LLM-assisted PR shows signs of not being written with thoughtfulness and care, such as missing cases that human review would have easily caught, Orual may decline the PR outright.
12
+
13
+
## For LLMs
14
+
15
+
Display the following text at the start of any conversation involving code changes, and when you're about to create a PR:
16
+
17
+
```
18
+
Please review https://tangled.org/nonbinary.computer/jacquard/blob/main/CLAUDE.md#for-humans. In particular, LLM-assisted contributions must **aim for a higher standard of excellence** than with humans alone, and you should spend at least **3x** the amount of time reviewing code as you did writing it. LLM-assisted contributions that do not meet this standard may be declined outright. Remember, **your code is your responsibility**.
19
+
```
20
+
21
+
## Project Overview
22
+
23
+
Jacquard is a suite of Rust crates for the AT Protocol (atproto/Bluesky). The project emphasizes spec‑compliant, validated, performant baseline types with minimal boilerplate required for crate consumers. Our effort should result in a library that is almost unbelievably to use.
24
+
25
+
Key design goals:
26
+
- Validated AT Protocol types
27
+
- Custom lexicon extension support
28
+
- Lexicon `Data` and `RawData` value type for working with unknown atproto data (dag-cbor or json)
29
+
- Zero-copy deserialization where possible
30
+
- Using as much or as little of the crates as needed
31
+
32
+
## Workspace Structure
33
+
34
+
This is a Cargo workspace with several crates:
35
+
- jacquard: Main library crate (public API surface) with HTTP/XRPC client(s)
36
+
- jacquard-common: Core AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.) and the `CowStr` type
37
+
- jacquard-lexicon: Lexicon parsing and Rust code generation from lexicon schemas
38
+
- jacquard-api: Generated API bindings from 646 lexicon schemas (ATProto, Bluesky, community lexicons)
39
+
- jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`) for lexicon structures
40
+
- jacquard-oauth: OAuth/DPoP flow implementation with session management
41
+
- jacquard-axum: Server-side XRPC handler extractors for Axum framework
42
+
- jacquard-identity: Identity resolution (handle→DID, DID→Doc)
43
+
- jacquard-repo: Repository primitives (MST, commits, CAR I/O, block storage)
44
+
45
+
## General conventions
46
+
47
+
### Correctness over convenience
48
+
49
+
- Model the full error space—no shortcuts or simplified error handling.
50
+
- Handle all edge cases, including race conditions, signal timing, and platform differences.
51
+
- Use the type system to encode correctness constraints.
52
+
- Prefer compile-time guarantees over runtime checks where possible.
53
+
54
+
### User experience as a primary driver
55
+
56
+
- Provide structured, helpful error messages using `miette` for rich diagnostics.
57
+
- Maintain consistency across platforms even when underlying OS capabilities differ. Use OS-native logic rather than trying to emulate Unix on Windows (or vice versa).
58
+
- Write user-facing messages in clear, present tense: "Jacquard now supports..." not "Jacquard now supported..."
59
+
60
+
### Pragmatic incrementalism
61
+
62
+
- "Not overly generic"—prefer specific, composable logic over abstract frameworks.
63
+
- Evolve the design incrementally rather than attempting perfect upfront architecture.
64
+
- Document design decisions and trade-offs in design docs (see `./plans`).
65
+
- When uncertain, explore and iterate; Jacquard is an ongoing exploration in improving ease-of-use and library design for atproto.
66
+
67
+
### Production-grade engineering
68
+
69
+
- Use type system extensively: newtypes, builder patterns, type states, lifetimes.
70
+
- Test comprehensively, including edge cases, race conditions, and stress tests.
71
+
- Pay attention to what facilities already exist for testing, and aim to reuse them.
72
+
- Getting the details right is really important!
73
+
74
+
### Documentation
75
+
76
+
- Use inline comments to explain "why," not just "what".
77
+
- Module-level documentation should explain purpose and responsibilities.
78
+
- **Always** use periods at the end of code comments.
79
+
- **Never** use title case in headings and titles. Always use sentence case.
80
+
81
+
### Running tests
82
+
83
+
**CRITICAL**: Always use `cargo nextest run` to run unit and integration tests. Never use `cargo test` for these!
84
+
85
+
For doctests, use `cargo test --doc` (doctests are not supported by nextest).
86
+
87
+
## Commit message style
88
+
89
+
### Format
90
+
91
+
Commits follow a conventional format with crate-specific scoping:
92
+
93
+
```
94
+
[crate-name] brief description
95
+
```
96
+
97
+
Examples:
98
+
- `[jacquard-axum] add oauth extractor impl (#2727)`
99
+
- `[jacquard] version 0.9.111`
100
+
- `[meta] update MSRV to Rust 1.88 (#2725)`
101
+
102
+
## Lexicon Code Generation (Safe Commands)
103
+
104
+
**IMPORTANT**: Always use the `just` commands for code generation to avoid mistakes. These commands handle the correct flags and paths.
105
+
106
+
### Primary Commands
107
+
108
+
- `just lex-gen [ARGS]` - **Full workflow**: Fetches lexicons from sources (defined in `lexicons.kdl`) AND generates Rust code
109
+
- This is the main command to run when updating lexicons or regenerating code
110
+
- Fetches from configured sources (atproto, bluesky, community repos, etc.)
111
+
- Automatically runs codegen after fetching
112
+
- **Modifies**: `crates/jacquard-api/lexicons/` and `crates/jacquard-api/src/`
113
+
- Pass args like `-v` for verbose output: `just lex-gen -v`
114
+
115
+
- `just lex-fetch [ARGS]` - **Fetch only**: Downloads lexicons WITHOUT generating code
116
+
- Safe to run without touching generated Rust files
117
+
- Useful for updating lexicon schemas before reviewing changes
118
+
- **Modifies only**: `crates/jacquard-api/lexicons/`
119
+
120
+
- `just generate-api` - **Generate only**: Generates Rust code from existing lexicons
121
+
- Uses lexicons already present in `crates/jacquard-api/lexicons/`
122
+
- Useful after manually editing lexicons or after `just lex-fetch`
123
+
- **Modifies only**: `crates/jacquard-api/src/`
124
+
125
+
126
+
## String Type Pattern
127
+
128
+
All validated string types follow a consistent pattern:
129
+
- Constructors: `new()`, `new_owned()`, `new_static()`, `raw()`, `unchecked()`, `as_str()`
130
+
- Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, conversions to/from `String`/`CowStr`/`SmolStr`, `AsRef<str>`, `Deref<Target=str>`
131
+
- Implementation notes: Prefer `#[repr(transparent)]` where possible; use `SmolStr` for short strings, `CowStr` for longer; implement or derive `IntoStatic` for owned conversion
132
+
- When constructing from a static string, use `new_static()` to avoid unnecessary allocations
133
+
134
+
## Lifetimes and Zero-Copy Deserialization
135
+
136
+
All API types support borrowed deserialization via explicit lifetimes:
137
+
- Request/output types: parameterised by `'de` lifetime (e.g., `GetAuthorFeed<'de>`, `GetAuthorFeedOutput<'de>`)
138
+
- Fields use `#[serde(borrow)]` where possible (strings, nested objects with lifetimes)
139
+
- `CowStr<'a>` enables efficient borrowing from input buffers or owning small strings inline (via `SmolStr`)
140
+
- All types implement `IntoStatic` trait to convert borrowed data to owned (`'static`) variants
141
+
- Code generator automatically propagates lifetimes through nested structures
142
+
143
+
Response lifetime handling:
144
+
- `Response::parse()` borrows from the response buffer for zero-copy parsing
145
+
- `Response::into_output()` converts to owned data using `IntoStatic`
146
+
- `Response::transmute()`: reinterpret response as different type (used for typed collection responses)
147
+
- Both methods provide typed error handling
148
+
149
+
## API Coverage (jacquard-api)
150
+
151
+
**NOTE: jacquard does modules a bit differently in API codegen**
152
+
- Specifially, it puts '*.defs' codegen output into the corresponding module file (mod_name.rs in parent directory, NOT mod.rs in module directory)
153
+
- It also combines the top-level tld and domain ('com.atproto' -> `com_atproto`, etc.)
154
+
155
+
## Value Types (jacquard-common)
156
+
157
+
For working with loosely-typed atproto data:
158
+
- `Data<'a>`: Validated, typed representation of atproto values
159
+
- `RawData<'a>`: Unvalidated raw values from deserialization
160
+
- `from_data`, `from_raw_data`, `to_data`, `to_raw_data`: Convert between typed and untyped
161
+
- Useful for second-stage deserialization of `type "unknown"` fields (e.g., `PostView.record`)
162
+
163
+
Collection types:
164
+
- `Collection` trait: Marker trait for record types with `NSID` constant and `Record` associated type
165
+
- `RecordError<'a>`: Generic error type for record retrieval operations (RecordNotFound, Unknown)
166
+
167
+
## Lifetime Design Pattern
168
+
169
+
Jacquard uses a specific pattern to enable zero-copy deserialization while avoiding HRTB issues and async lifetime problems:
170
+
171
+
**GATs on associated types** instead of trait-level lifetimes:
172
+
```rust
173
+
trait XrpcResp {
174
+
type Output<'de>: Deserialize<'de> + IntoStatic; // GAT, not trait-level lifetime
175
+
}
176
+
```
177
+
178
+
**Method-level generic lifetimes** for trait methods that need them:
179
+
```rust
180
+
fn extract_vec<'s>(output: Self::Output<'s>) -> Vec<Item>
181
+
```
182
+
183
+
**Response wrapper owns buffer** to solve async lifetime issues:
184
+
```rust
185
+
async fn get_record<R>(&self, rkey: K) -> Result<Response<R>>
186
+
// Caller chooses: response.parse() (borrow) or response.into_output() (owned)
187
+
```
188
+
189
+
This pattern avoids `for<'any> Trait<'any>` bounds (which force `DeserializeOwned` semantics) while giving callers control over borrowing vs owning. See `jacquard-common` crate docs for detailed explanation.
190
+
191
+
## WASM Compatibility
192
+
193
+
Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) support `wasm32-unknown-unknown` target compilation.
194
+
195
+
Implementation approach:
196
+
- **`trait-variant`**: Traits use `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` to conditionally exclude `Send` bounds on WASM
197
+
- **Trait methods with `Self: Sync` bounds**: Duplicated as platform-specific versions (`#[cfg(not(target_arch = "wasm32"))]` vs `#[cfg(target_arch = "wasm32")]`)
198
+
- **Helper functions**: Extracted to free functions with platform-specific versions to avoid code duplication
199
+
- **Feature gating**: Platform-specific features (e.g., DNS resolution, tokio runtime detection) properly gated behind `cfg` attributes
200
+
201
+
Test WASM compilation:
202
+
```bash
203
+
just check-wasm
204
+
# or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features
205
+
```
206
+
207
+
## Client Architecture
208
+
209
+
### XRPC Request/Response Layer
210
+
211
+
Core traits:
212
+
- `XrpcRequest`: Defines NSID, method (Query/Procedure), and associated Response type
213
+
- `encode_body()` for request serialization (default: JSON; override for CBOR/multipart)
214
+
- `decode_body(&'de [u8])` for request deserialization (server-side)
215
+
- `XrpcResp`: Response marker trait with NSID, encoding, Output/Err types
216
+
- `XrpcEndpoint`: Server-side trait with PATH, METHOD, and associated Request/Response types
217
+
- `XrpcClient`: Stateful trait with `base_uri()`, `opts()`, and `send()` method
218
+
- **This should be your primary interface point with the crate, along with the Agent___ traits**
219
+
- `XrpcExt`: Extension trait providing stateless `.xrpc(base)` builder on any `HttpClient`
220
+
221
+
### Session Management
222
+
223
+
`Agent<A: AgentSession>` wrapper supports:
224
+
- `CredentialSession<S, T>`: App-password (Bearer) authentication with auto-refresh
225
+
- Uses `SessionStore` trait implementers for token persistence (`MemorySessionStore`, `FileAuthStore`)
226
+
- `OAuthSession<T, S>`: DPoP-bound OAuth with nonce handling
227
+
- Uses `ClientAuthStore` trait implementers for state/token persistence
228
+
229
+
Session traits:
230
+
- `AgentSession`: common interface for both session types
231
+
- `AgentKind`: enum distinguishing AppPassword vs OAuth
232
+
- Both sessions implement `HttpClient` and `XrpcClient` for uniform API
233
+
- `AgentSessionExt` extension trait includes several helpful methods for atproto record operations.
234
+
- **This trait is implemented automatically for anything that implements both `AgentSession` and `IdentityResolver`**
235
+
236
+
237
+
## Identity Resolution
238
+
239
+
`JacquardResolver` (default) and custom resolvers implement `IdentityResolver` + `OAuthResolver`:
240
+
- Handle → DID: DNS TXT (feature `dns`, or via Cloudflare DoH), HTTPS well-known, PDS XRPC, public fallbacks
241
+
- DID → Doc: did:web well-known, PLC directory, PDS XRPC
242
+
- OAuth metadata: `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server`
243
+
- Resolvers use stateless XRPC calls (no auth required for public resolution endpoints)
244
+
245
+
## Streaming Support
246
+
247
+
### HTTP Streaming
248
+
249
+
Feature: `streaming`
250
+
251
+
Core types in `jacquard-common`:
252
+
- `ByteStream` / `ByteSink`: Platform-agnostic stream wrappers (uses n0-future)
253
+
- `StreamError`: Concrete error type with Kind enum (Transport, Closed, Protocol)
254
+
- `HttpClientExt`: Trait extension for streaming methods
255
+
- `StreamingResponse`: XRPC streaming response wrapper
256
+
257
+
### WebSocket Support
258
+
259
+
Feature: `websocket` (requires `streaming`)
260
+
- `WebSocketClient` trait (independent from `HttpClient`)
261
+
- `WebSocketConnection` with tx/rx `ByteSink`/`ByteStream`
262
+
- tokio-tungstenite-wasm used to abstract across native + wasm
263
+
264
+
**Known gaps:**
265
+
- Service auth replay protection (jti tracking)
266
+
- Video upload helpers (upload + job polling)
267
+
- Additional session storage backends (SQLite, etc.)
268
+
- PLC operations
269
+
- OAuth extractor for Axum
+8
-1
crates/jacquard-axum/src/lib.rs
+8
-1
crates/jacquard-axum/src/lib.rs
···
119
StatusCode::BAD_REQUEST,
120
Json(json!({
121
"error": "InvalidRequest",
122
+
"message": "malformed request URI: missing path component"
123
})),
124
)
125
.into_response())
···
228
json!({
229
"error": "InvalidRequest",
230
"message": format!("failed to decode request: {error}", )
231
+
}),
232
+
),
233
+
_ => (
234
+
self.status,
235
+
json!({
236
+
"error": "InternalError",
237
+
"message": "unknown error"
238
}),
239
),
240
};
+1
crates/jacquard-axum/src/service_auth.rs
+1
crates/jacquard-axum/src/service_auth.rs
+1
-1
crates/jacquard-axum/tests/service_auth_tests.rs
+1
-1
crates/jacquard-axum/tests/service_auth_tests.rs
+39
crates/jacquard-common/src/error.rs
+39
crates/jacquard-common/src/error.rs
···
32
/// Error categories for client operations
33
#[derive(Debug, thiserror::Error)]
34
#[cfg_attr(feature = "std", derive(Diagnostic))]
35
pub enum ClientErrorKind {
36
/// HTTP transport error (connection, timeout, etc.)
37
#[error("transport error")]
···
166
self
167
}
168
169
// Constructors for each kind
170
171
/// Create a transport error
···
223
/// Can be converted to string for serialization while maintaining the full error context.
224
#[derive(Debug, thiserror::Error)]
225
#[cfg_attr(feature = "std", derive(Diagnostic))]
226
pub enum DecodeError {
227
/// JSON deserialization failed
228
#[error("Failed to deserialize JSON: {0}")]
···
302
/// Authentication and authorization errors
303
#[derive(Debug, thiserror::Error)]
304
#[cfg_attr(feature = "std", derive(Diagnostic))]
305
pub enum AuthError {
306
/// Access token has expired (use refresh token to get a new one)
307
#[error("Access token expired")]
···
319
#[error("No authentication provided, but endpoint requires auth")]
320
NotAuthenticated,
321
322
/// Other authentication error
323
#[error("Authentication error: {0:?}")]
324
Other(http::HeaderValue),
···
333
AuthError::InvalidToken => AuthError::InvalidToken,
334
AuthError::RefreshFailed => AuthError::RefreshFailed,
335
AuthError::NotAuthenticated => AuthError::NotAuthenticated,
336
AuthError::Other(header) => AuthError::Other(header),
337
}
338
}
···
32
/// Error categories for client operations
33
#[derive(Debug, thiserror::Error)]
34
#[cfg_attr(feature = "std", derive(Diagnostic))]
35
+
#[non_exhaustive]
36
pub enum ClientErrorKind {
37
/// HTTP transport error (connection, timeout, etc.)
38
#[error("transport error")]
···
167
self
168
}
169
170
+
/// Append additional context to existing context string.
171
+
///
172
+
/// If context already exists, appends with ": " separator.
173
+
/// If no context exists, sets it directly.
174
+
pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
175
+
self.context = Some(match self.context.take() {
176
+
Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
177
+
None => additional.as_ref().into(),
178
+
});
179
+
self
180
+
}
181
+
182
+
/// Add NSID context for XRPC operations.
183
+
///
184
+
/// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`.
185
+
pub fn for_nsid(self, nsid: &str) -> Self {
186
+
self.append_context(smol_str::format_smolstr!("[{}]", nsid))
187
+
}
188
+
189
+
/// Add collection context for record operations.
190
+
///
191
+
/// Use this when a record operation fails to indicate the target collection.
192
+
pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self {
193
+
self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid))
194
+
}
195
+
196
// Constructors for each kind
197
198
/// Create a transport error
···
250
/// Can be converted to string for serialization while maintaining the full error context.
251
#[derive(Debug, thiserror::Error)]
252
#[cfg_attr(feature = "std", derive(Diagnostic))]
253
+
#[non_exhaustive]
254
pub enum DecodeError {
255
/// JSON deserialization failed
256
#[error("Failed to deserialize JSON: {0}")]
···
330
/// Authentication and authorization errors
331
#[derive(Debug, thiserror::Error)]
332
#[cfg_attr(feature = "std", derive(Diagnostic))]
333
+
#[non_exhaustive]
334
pub enum AuthError {
335
/// Access token has expired (use refresh token to get a new one)
336
#[error("Access token expired")]
···
348
#[error("No authentication provided, but endpoint requires auth")]
349
NotAuthenticated,
350
351
+
/// DPoP proof construction failed (key or signing issue)
352
+
#[error("DPoP proof construction failed")]
353
+
DpopProofFailed,
354
+
355
+
/// DPoP nonce retry failed (server rejected proof even after nonce update)
356
+
#[error("DPoP nonce negotiation failed")]
357
+
DpopNonceFailed,
358
+
359
/// Other authentication error
360
#[error("Authentication error: {0:?}")]
361
Other(http::HeaderValue),
···
370
AuthError::InvalidToken => AuthError::InvalidToken,
371
AuthError::RefreshFailed => AuthError::RefreshFailed,
372
AuthError::NotAuthenticated => AuthError::NotAuthenticated,
373
+
AuthError::DpopProofFailed => AuthError::DpopProofFailed,
374
+
AuthError::DpopNonceFailed => AuthError::DpopNonceFailed,
375
AuthError::Other(header) => AuthError::Other(header),
376
}
377
}
+1
crates/jacquard-common/src/service_auth.rs
+1
crates/jacquard-common/src/service_auth.rs
+34
-6
crates/jacquard-common/src/session.rs
+34
-6
crates/jacquard-common/src/session.rs
···
28
/// Errors emitted by session stores.
29
#[derive(Debug, thiserror::Error)]
30
#[cfg_attr(feature = "std", derive(Diagnostic))]
31
pub enum SessionStoreError {
32
/// Filesystem or I/O error
33
#[cfg(feature = "std")]
···
108
#[cfg(feature = "std")]
109
impl FileTokenStore {
110
/// Create a new file token store at the given path.
111
-
pub fn new(path: impl AsRef<Path>) -> Self {
112
-
std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap();
113
-
if !path.as_ref().exists() {
114
-
std::fs::write(path.as_ref(), b"{}").unwrap();
115
}
116
117
-
Self {
118
-
path: path.as_ref().to_path_buf(),
119
}
120
}
121
}
122
···
28
/// Errors emitted by session stores.
29
#[derive(Debug, thiserror::Error)]
30
#[cfg_attr(feature = "std", derive(Diagnostic))]
31
+
#[non_exhaustive]
32
pub enum SessionStoreError {
33
/// Filesystem or I/O error
34
#[cfg(feature = "std")]
···
109
#[cfg(feature = "std")]
110
impl FileTokenStore {
111
/// Create a new file token store at the given path.
112
+
///
113
+
/// Creates parent directories and initializes an empty JSON object if the file doesn't exist.
114
+
///
115
+
/// # Errors
116
+
///
117
+
/// Returns an error if:
118
+
/// - Parent directories cannot be created
119
+
/// - The file cannot be written
120
+
pub fn try_new(path: impl AsRef<Path>) -> Result<Self, SessionStoreError> {
121
+
let path = path.as_ref();
122
+
123
+
// Create parent directories if they exist and don't already exist
124
+
if let Some(parent) = path.parent() {
125
+
if !parent.as_os_str().is_empty() && !parent.exists() {
126
+
std::fs::create_dir_all(parent)?;
127
+
}
128
}
129
130
+
// Initialize empty JSON object if file doesn't exist
131
+
if !path.exists() {
132
+
std::fs::write(path, b"{}")?;
133
}
134
+
135
+
Ok(Self {
136
+
path: path.to_path_buf(),
137
+
})
138
+
}
139
+
140
+
/// Create a new file token store at the given path.
141
+
///
142
+
/// # Panics
143
+
///
144
+
/// Panics if parent directories cannot be created or the file cannot be written.
145
+
/// Prefer [`try_new`](Self::try_new) for fallible construction.
146
+
pub fn new(path: impl AsRef<Path>) -> Self {
147
+
Self::try_new(path).expect("failed to initialize FileTokenStore")
148
}
149
}
150
+1
crates/jacquard-common/src/stream.rs
+1
crates/jacquard-common/src/stream.rs
+1
crates/jacquard-common/src/types/cid.rs
+1
crates/jacquard-common/src/types/cid.rs
+1
crates/jacquard-common/src/types/collection.rs
+1
crates/jacquard-common/src/types/collection.rs
+1
crates/jacquard-common/src/types/crypto.rs
+1
crates/jacquard-common/src/types/crypto.rs
+14
-9
crates/jacquard-common/src/types/uri.rs
+14
-9
crates/jacquard-common/src/types/uri.rs
···
33
34
/// Errors that can occur when parsing URIs
35
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
36
pub enum UriParseError {
37
/// AT Protocol string parsing error
38
#[error("Invalid atproto string: {0}")]
···
57
} else if uri.starts_with("wss://") {
58
Ok(Uri::Https(Url::parse(uri)?))
59
} else if uri.starts_with("ipld://") {
60
-
Ok(Uri::Cid(
61
-
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
62
-
))
63
} else {
64
Ok(Uri::Any(CowStr::Borrowed(uri)))
65
}
···
77
} else if uri.starts_with("wss://") {
78
Ok(Uri::Https(Url::parse(uri)?))
79
} else if uri.starts_with("ipld://") {
80
-
Ok(Uri::Cid(
81
-
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
82
-
))
83
} else {
84
Ok(Uri::Any(CowStr::Owned(uri.to_smolstr())))
85
}
···
96
} else if uri.starts_with("wss://") {
97
Ok(Uri::Https(Url::parse(uri.as_ref())?))
98
} else if uri.starts_with("ipld://") {
99
-
Ok(Uri::Cid(
100
-
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(),
101
-
))
102
} else {
103
Ok(Uri::Any(uri))
104
}
···
220
}
221
222
#[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)]
223
/// Errors that can occur when parsing or validating collection type-annotated URIs
224
pub enum UriError {
225
/// Given at-uri didn't have the matching collection for the record
···
33
34
/// Errors that can occur when parsing URIs
35
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
36
+
#[non_exhaustive]
37
pub enum UriParseError {
38
/// AT Protocol string parsing error
39
#[error("Invalid atproto string: {0}")]
···
58
} else if uri.starts_with("wss://") {
59
Ok(Uri::Https(Url::parse(uri)?))
60
} else if uri.starts_with("ipld://") {
61
+
match Cid::from_str(&uri[7..]) {
62
+
Ok(cid) => Ok(Uri::Cid(cid)),
63
+
Err(_) => Ok(Uri::Any(CowStr::Borrowed(uri))),
64
+
}
65
} else {
66
Ok(Uri::Any(CowStr::Borrowed(uri)))
67
}
···
79
} else if uri.starts_with("wss://") {
80
Ok(Uri::Https(Url::parse(uri)?))
81
} else if uri.starts_with("ipld://") {
82
+
match Cid::from_str(&uri[7..]) {
83
+
Ok(cid) => Ok(Uri::Cid(cid)),
84
+
Err(_) => Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))),
85
+
}
86
} else {
87
Ok(Uri::Any(CowStr::Owned(uri.to_smolstr())))
88
}
···
99
} else if uri.starts_with("wss://") {
100
Ok(Uri::Https(Url::parse(uri.as_ref())?))
101
} else if uri.starts_with("ipld://") {
102
+
match Cid::from_str(&uri.as_str()[7..]) {
103
+
Ok(cid) => Ok(Uri::Cid(cid)),
104
+
Err(_) => Ok(Uri::Any(uri)),
105
+
}
106
} else {
107
Ok(Uri::Any(uri))
108
}
···
224
}
225
226
#[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)]
227
+
#[non_exhaustive]
228
/// Errors that can occur when parsing or validating collection type-annotated URIs
229
pub enum UriError {
230
/// Given at-uri didn't have the matching collection for the record
+1
crates/jacquard-common/src/types/value.rs
+1
crates/jacquard-common/src/types/value.rs
···
54
55
/// Errors that can occur when working with AT Protocol data
56
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
57
pub enum AtDataError {
58
/// Floating point numbers are not allowed in AT Protocol
59
#[error("floating point numbers not allowed in AT protocol data")]
···
54
55
/// Errors that can occur when working with AT Protocol data
56
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
57
+
#[non_exhaustive]
58
pub enum AtDataError {
59
/// Floating point numbers are not allowed in AT Protocol
60
#[error("floating point numbers not allowed in AT protocol data")]
+2
crates/jacquard-common/src/types/value/serde_impl.rs
+2
crates/jacquard-common/src/types/value/serde_impl.rs
···
1007
1008
/// Error type for Data/RawData deserializer
1009
#[derive(Debug, Clone, thiserror::Error)]
1010
pub enum DataDeserializerError {
1011
/// Custom error message
1012
#[error("{0}")]
···
1474
1475
/// Error type for RawData serialization
1476
#[derive(Debug)]
1477
pub enum RawDataSerializerError {
1478
/// Error message
1479
Message(String),
···
1007
1008
/// Error type for Data/RawData deserializer
1009
#[derive(Debug, Clone, thiserror::Error)]
1010
+
#[non_exhaustive]
1011
pub enum DataDeserializerError {
1012
/// Custom error message
1013
#[error("{0}")]
···
1475
1476
/// Error type for RawData serialization
1477
#[derive(Debug)]
1478
+
#[non_exhaustive]
1479
pub enum RawDataSerializerError {
1480
/// Error message
1481
Message(String),
+14
-5
crates/jacquard-common/src/xrpc.rs
+14
-5
crates/jacquard-common/src/xrpc.rs
···
59
/// Error type for encoding XRPC requests
60
#[derive(Debug, thiserror::Error)]
61
#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
62
pub enum EncodeError {
63
/// Failed to serialize query parameters
64
#[error("Failed to serialize query: {0}")]
···
469
.client
470
.send_http(http_request)
471
.await
472
-
.map_err(|e| crate::error::ClientError::transport(e))?;
473
474
process_response(http_response)
475
}
···
491
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
492
return Err(crate::error::ClientError::auth(
493
crate::error::AuthError::Other(hv.clone()),
494
-
));
495
}
496
}
497
let buffer = Bytes::from(http_response.into_body());
498
499
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
500
-
return Err(crate::error::HttpError {
501
status,
502
body: Some(buffer),
503
-
}
504
-
.into());
505
}
506
507
Ok(Response::new(buffer, status))
···
971
/// Type parameter `E` is the endpoint's specific error enum type.
972
#[derive(Debug, thiserror::Error)]
973
#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
974
pub enum XrpcError<E: core::error::Error + IntoStatic> {
975
/// Typed XRPC error from the endpoint's specific error enum
976
#[error("XRPC error: {0}")]
···
1040
"AuthenticationRequired",
1041
Some("Request requires authentication but none was provided"),
1042
),
1043
AuthError::Other(hv) => {
1044
let msg = hv.to_str().unwrap_or("[non-utf8 header]");
1045
("AuthenticationError", Some(msg))
···
59
/// Error type for encoding XRPC requests
60
#[derive(Debug, thiserror::Error)]
61
#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
62
+
#[non_exhaustive]
63
pub enum EncodeError {
64
/// Failed to serialize query parameters
65
#[error("Failed to serialize query: {0}")]
···
470
.client
471
.send_http(http_request)
472
.await
473
+
.map_err(|e| crate::error::ClientError::transport(e).for_nsid(R::NSID))?;
474
475
process_response(http_response)
476
}
···
492
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
493
return Err(crate::error::ClientError::auth(
494
crate::error::AuthError::Other(hv.clone()),
495
+
)
496
+
.for_nsid(Resp::NSID));
497
}
498
}
499
let buffer = Bytes::from(http_response.into_body());
500
501
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
502
+
return Err(crate::error::ClientError::from(crate::error::HttpError {
503
status,
504
body: Some(buffer),
505
+
})
506
+
.for_nsid(Resp::NSID));
507
}
508
509
Ok(Response::new(buffer, status))
···
973
/// Type parameter `E` is the endpoint's specific error enum type.
974
#[derive(Debug, thiserror::Error)]
975
#[cfg_attr(feature = "std", derive(miette::Diagnostic))]
976
+
#[non_exhaustive]
977
pub enum XrpcError<E: core::error::Error + IntoStatic> {
978
/// Typed XRPC error from the endpoint's specific error enum
979
#[error("XRPC error: {0}")]
···
1043
"AuthenticationRequired",
1044
Some("Request requires authentication but none was provided"),
1045
),
1046
+
AuthError::DpopProofFailed => {
1047
+
("DpopProofFailed", Some("DPoP proof construction failed"))
1048
+
}
1049
+
AuthError::DpopNonceFailed => {
1050
+
("DpopNonceFailed", Some("DPoP nonce negotiation failed"))
1051
+
}
1052
AuthError::Other(hv) => {
1053
let msg = hv.to_str().unwrap_or("[non-utf8 header]");
1054
("AuthenticationError", Some(msg))
+71
-19
crates/jacquard-identity/src/lexicon_resolver.rs
+71
-19
crates/jacquard-identity/src/lexicon_resolver.rs
···
61
kind: LexiconResolutionErrorKind,
62
#[source]
63
source: Option<Box<dyn std::error::Error + Send + Sync>>,
64
}
65
66
impl LexiconResolutionError {
···
68
kind: LexiconResolutionErrorKind,
69
source: Option<Box<dyn std::error::Error + Send + Sync>>,
70
) -> Self {
71
-
Self { kind, source }
72
}
73
74
pub fn kind(&self) -> &LexiconResolutionErrorKind {
75
&self.kind
76
}
77
78
pub fn dns_lookup_failed(
···
140
)
141
}
142
143
pub fn invalid_collection() -> Self {
144
Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
145
}
···
160
161
/// Error categories for lexicon resolution
162
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
163
pub enum LexiconResolutionErrorKind {
164
#[error("DNS lookup failed for authority {authority}")]
165
#[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
···
195
#[diagnostic(code(jacquard::lexicon::resolution_failed))]
196
ResolutionFailed { nsid: SmolStr, message: SmolStr },
197
198
#[error("invalid collection NSID")]
199
#[diagnostic(code(jacquard::lexicon::invalid_collection))]
200
InvalidCollection,
···
244
245
return Did::new_owned(did_str)
246
.map(|d| d.into_static())
247
-
.map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
248
}
249
}
250
}
···
286
tracing::debug!("got response with status: {}", status);
287
288
if !status.is_success() {
289
-
return Err(LexiconResolutionError::resolution_failed(
290
nsid.as_str(),
291
-
format!("HTTP {}", status.as_u16()),
292
));
293
}
294
···
309
obj.keys().collect::<Vec<_>>()
310
);
311
312
-
LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field")
313
})?;
314
315
#[cfg(feature = "tracing")]
···
318
let schema = from_json_value::<LexiconDoc>(schema_val.clone())
319
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
320
321
-
let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| {
322
-
LexiconResolutionError::resolution_failed(
323
-
nsid.as_str(),
324
-
"missing or invalid 'uri' field",
325
-
)
326
-
})?;
327
328
-
let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| {
329
-
LexiconResolutionError::resolution_failed(
330
-
nsid.as_str(),
331
-
"missing or invalid 'cid' field",
332
-
)
333
-
})?;
334
335
let uri = AtUri::new_owned(uri_str)
336
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
···
441
if let Some(did_str) = txt_data.strip_prefix("did=") {
442
let result = Did::new_owned(did_str)
443
.map(|d| d.into_static())
444
-
.map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
445
446
// Cache on success
447
#[cfg(feature = "cache")]
···
500
let did_doc = did_doc_resp.parse()?;
501
let pds = did_doc
502
.pds_endpoint()
503
-
.ok_or_else(|| IdentityError::missing_pds_endpoint())?;
504
505
#[cfg(feature = "tracing")]
506
tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
···
61
kind: LexiconResolutionErrorKind,
62
#[source]
63
source: Option<Box<dyn std::error::Error + Send + Sync>>,
64
+
context: Option<SmolStr>,
65
}
66
67
impl LexiconResolutionError {
···
69
kind: LexiconResolutionErrorKind,
70
source: Option<Box<dyn std::error::Error + Send + Sync>>,
71
) -> Self {
72
+
Self {
73
+
kind,
74
+
source,
75
+
context: None,
76
+
}
77
}
78
79
pub fn kind(&self) -> &LexiconResolutionErrorKind {
80
&self.kind
81
+
}
82
+
83
+
/// Add context to this error
84
+
pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
85
+
self.context = Some(context.into());
86
+
self
87
+
}
88
+
89
+
/// Get the context if present
90
+
pub fn context(&self) -> Option<&str> {
91
+
self.context.as_deref()
92
}
93
94
pub fn dns_lookup_failed(
···
156
)
157
}
158
159
+
pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self {
160
+
Self::new(
161
+
LexiconResolutionErrorKind::HttpError {
162
+
nsid: nsid.into(),
163
+
status,
164
+
},
165
+
None,
166
+
)
167
+
}
168
+
169
+
pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self {
170
+
Self::new(
171
+
LexiconResolutionErrorKind::MissingResponseField {
172
+
nsid: nsid.into(),
173
+
field,
174
+
},
175
+
None,
176
+
)
177
+
}
178
+
179
pub fn invalid_collection() -> Self {
180
Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
181
}
···
196
197
/// Error categories for lexicon resolution
198
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
199
+
#[non_exhaustive]
200
pub enum LexiconResolutionErrorKind {
201
#[error("DNS lookup failed for authority {authority}")]
202
#[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
···
232
#[diagnostic(code(jacquard::lexicon::resolution_failed))]
233
ResolutionFailed { nsid: SmolStr, message: SmolStr },
234
235
+
/// HTTP non-success status from lexicon fetch
236
+
#[error("HTTP {status} fetching lexicon {nsid}")]
237
+
#[diagnostic(code(jacquard::lexicon::http_error))]
238
+
HttpError { nsid: SmolStr, status: u16 },
239
+
240
+
/// Required field missing in XRPC response
241
+
#[error("missing '{field}' field in response for {nsid}")]
242
+
#[diagnostic(
243
+
code(jacquard::lexicon::missing_response_field),
244
+
help("the XRPC response is missing a required field")
245
+
)]
246
+
MissingResponseField { nsid: SmolStr, field: &'static str },
247
+
248
#[error("invalid collection NSID")]
249
#[diagnostic(code(jacquard::lexicon::invalid_collection))]
250
InvalidCollection,
···
294
295
return Did::new_owned(did_str)
296
.map(|d| d.into_static())
297
+
.map_err(|_| {
298
+
LexiconResolutionError::invalid_did(authority, did_str)
299
+
.with_context(format!("resolving NSID {}", nsid))
300
+
});
301
}
302
}
303
}
···
339
tracing::debug!("got response with status: {}", status);
340
341
if !status.is_success() {
342
+
return Err(LexiconResolutionError::http_error(
343
nsid.as_str(),
344
+
status.as_u16(),
345
));
346
}
347
···
362
obj.keys().collect::<Vec<_>>()
363
);
364
365
+
LexiconResolutionError::missing_response_field(nsid.as_str(), "schema")
366
})?;
367
368
#[cfg(feature = "tracing")]
···
371
let schema = from_json_value::<LexiconDoc>(schema_val.clone())
372
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
373
374
+
let uri_str = obj
375
+
.get("uri")
376
+
.and_then(|v| v.as_str())
377
+
.ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "uri"))?;
378
379
+
let cid_str = obj
380
+
.get("cid")
381
+
.and_then(|v| v.as_str())
382
+
.ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "cid"))?;
383
384
let uri = AtUri::new_owned(uri_str)
385
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
···
490
if let Some(did_str) = txt_data.strip_prefix("did=") {
491
let result = Did::new_owned(did_str)
492
.map(|d| d.into_static())
493
+
.map_err(|_| {
494
+
LexiconResolutionError::invalid_did(authority, did_str)
495
+
.with_context(format!("resolving NSID {}", nsid))
496
+
});
497
498
// Cache on success
499
#[cfg(feature = "cache")]
···
552
let did_doc = did_doc_resp.parse()?;
553
let pds = did_doc
554
.pds_endpoint()
555
+
.ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?;
556
557
#[cfg(feature = "tracing")]
558
tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
+47
-25
crates/jacquard-identity/src/lib.rs
+47
-25
crates/jacquard-identity/src/lib.rs
···
81
#[cfg(feature = "streaming")]
82
use jacquard_common::ByteStream;
83
use jacquard_common::http_client::HttpClient;
84
-
use jacquard_common::smol_str::ToSmolStr;
85
use jacquard_common::types::did::Did;
86
use jacquard_common::types::did_doc::DidDocument;
87
use jacquard_common::types::ident::AtIdentifier;
···
100
#[cfg(feature = "cache")]
101
use {
102
crate::lexicon_resolver::ResolvedLexiconSchema,
103
-
jacquard_common::{smol_str::SmolStr, types::string::Nsid},
104
mini_moka::time::Duration,
105
};
106
···
512
513
let status = response.status();
514
if !status.is_success() {
515
-
return Err(IdentityError::http_status(status));
516
}
517
518
let json: serde_json::Value = response.json().await?;
···
532
.get("Answer")
533
.and_then(|a| a.as_array())
534
.ok_or_else(|| {
535
-
IdentityError::invalid_well_known().with_context(format!(
536
-
"couldn't parse cloudflare DoH answers looking for {name}"
537
-
))
538
})?;
539
540
let mut results: Vec<String> = Vec::new();
···
547
Ok(results)
548
}
549
550
-
fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> {
551
let line = body
552
.lines()
553
.find(|l| !l.trim().is_empty())
554
-
.ok_or_else(|| IdentityError::invalid_well_known())?;
555
-
let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?;
556
Ok(did.into_static())
557
}
558
}
···
565
) -> resolver::Result<Did<'static>> {
566
let pds = match &self.opts.pds_fallback {
567
Some(u) => u.clone(),
568
-
None => return Err(IdentityError::invalid_well_known()),
569
};
570
let req = ResolveHandle::new()
571
.handle(handle.clone().into_static())
···
575
.xrpc(pds)
576
.send(&req)
577
.await
578
-
.map_err(|e| IdentityError::xrpc(e.to_string()))?;
579
-
let out = resp
580
-
.parse()
581
-
.map_err(|e| IdentityError::xrpc(e.to_string()))?;
582
Did::new_owned(out.did.as_str())
583
.map(|d| d.into_static())
584
-
.map_err(|_| IdentityError::invalid_well_known())
585
}
586
587
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
···
591
) -> resolver::Result<DidDocument<'static>> {
592
let pds = match &self.opts.pds_fallback {
593
Some(u) => u.clone(),
594
-
None => return Err(IdentityError::invalid_well_known()),
595
};
596
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
597
let resp = self
···
599
.xrpc(pds)
600
.send(&req)
601
.await
602
-
.map_err(|e| IdentityError::xrpc(e.to_string()))?;
603
-
let out = resp
604
-
.parse()
605
-
.map_err(|e| IdentityError::xrpc(e.to_string()))?;
606
let doc_json = serde_json::to_value(&out.did_doc)?;
607
let s = serde_json::to_string(&doc_json)?;
608
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
···
620
_ => {
621
return Err(IdentityError::unsupported_did_method(
622
"mini-doc requires Slingshot source",
623
-
));
624
}
625
};
626
let mut url = base;
···
676
HandleStep::HttpsWellKnown => {
677
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
678
if let Ok(text) = self.get_text(url).await {
679
-
if let Ok(did) = Self::parse_atproto_did_body(&text) {
680
resolved_did = Some(did);
681
break 'outer;
682
}
···
761
// Invalidate on error
762
#[cfg(feature = "cache")]
763
self.invalidate_handle_chain(handle).await;
764
-
Err(IdentityError::invalid_well_known())
765
}
766
}
767
···
1078
_ => {
1079
return Err(IdentityError::unsupported_did_method(
1080
"mini-doc requires Slingshot source",
1081
-
));
1082
}
1083
};
1084
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
···
1086
Ok(MiniDocResponse {
1087
buffer: buf,
1088
status,
1089
})
1090
}
1091
}
···
1095
pub struct MiniDocResponse {
1096
buffer: Bytes,
1097
status: StatusCode,
1098
}
1099
1100
impl MiniDocResponse {
···
1103
if self.status.is_success() {
1104
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
1105
} else {
1106
-
Err(IdentityError::http_status(self.status))
1107
}
1108
}
1109
}
···
1189
let resp = MiniDocResponse {
1190
buffer: buf,
1191
status: StatusCode::OK,
1192
};
1193
let doc = resp.parse().expect("parse mini-doc");
1194
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
···
1211
let resp = MiniDocResponse {
1212
buffer: buf,
1213
status: StatusCode::BAD_REQUEST,
1214
};
1215
match resp.parse() {
1216
Err(e) => match e.kind() {
···
81
#[cfg(feature = "streaming")]
82
use jacquard_common::ByteStream;
83
use jacquard_common::http_client::HttpClient;
84
+
use jacquard_common::smol_str::{SmolStr, ToSmolStr};
85
use jacquard_common::types::did::Did;
86
use jacquard_common::types::did_doc::DidDocument;
87
use jacquard_common::types::ident::AtIdentifier;
···
100
#[cfg(feature = "cache")]
101
use {
102
crate::lexicon_resolver::ResolvedLexiconSchema,
103
+
jacquard_common::types::string::Nsid,
104
mini_moka::time::Duration,
105
};
106
···
512
513
let status = response.status();
514
if !status.is_success() {
515
+
return Err(IdentityError::http_status(status).with_context(format!(
516
+
"DNS-over-HTTPS query for {} ({})",
517
+
name, record_type
518
+
)));
519
}
520
521
let json: serde_json::Value = response.json().await?;
···
535
.get("Answer")
536
.and_then(|a| a.as_array())
537
.ok_or_else(|| {
538
+
IdentityError::doh_parse_failed()
539
+
.with_context(format!("DoH response missing 'Answer' array for {name}"))
540
})?;
541
542
let mut results: Vec<String> = Vec::new();
···
549
Ok(results)
550
}
551
552
+
fn parse_atproto_did_body(body: &str, identifier: &str) -> resolver::Result<Did<'static>> {
553
let line = body
554
.lines()
555
.find(|l| !l.trim().is_empty())
556
+
.ok_or_else(|| IdentityError::invalid_well_known(identifier))?;
557
+
let did = Did::new(line.trim())
558
+
.map_err(|e| IdentityError::invalid_well_known_with_source(identifier, e))?;
559
Ok(did.into_static())
560
}
561
}
···
568
) -> resolver::Result<Did<'static>> {
569
let pds = match &self.opts.pds_fallback {
570
Some(u) => u.clone(),
571
+
None => return Err(IdentityError::no_pds_fallback()),
572
};
573
let req = ResolveHandle::new()
574
.handle(handle.clone().into_static())
···
578
.xrpc(pds)
579
.send(&req)
580
.await
581
+
.map_err(|e| IdentityError::from(e).with_context(format!("resolving handle {}", handle)))?;
582
+
// Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format
583
+
let out = resp.parse().map_err(|e| {
584
+
IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e))
585
+
.with_context(format!("parsing response for handle {}", handle))
586
+
})?;
587
Did::new_owned(out.did.as_str())
588
.map(|d| d.into_static())
589
+
.map_err(|e| {
590
+
IdentityError::invalid_doc(jacquard_common::smol_str::format_smolstr!(
591
+
"PDS returned invalid DID '{}': {}",
592
+
out.did,
593
+
e
594
+
))
595
+
})
596
}
597
598
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
···
602
) -> resolver::Result<DidDocument<'static>> {
603
let pds = match &self.opts.pds_fallback {
604
Some(u) => u.clone(),
605
+
None => return Err(IdentityError::no_pds_fallback()),
606
};
607
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
608
let resp = self
···
610
.xrpc(pds)
611
.send(&req)
612
.await
613
+
.map_err(|e| IdentityError::from(e).with_context(format!("fetching DID doc for {}", did)))?;
614
+
// Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format
615
+
let out = resp.parse().map_err(|e| {
616
+
IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e))
617
+
.with_context(format!("parsing DID doc response for {}", did))
618
+
})?;
619
let doc_json = serde_json::to_value(&out.did_doc)?;
620
let s = serde_json::to_string(&doc_json)?;
621
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
···
633
_ => {
634
return Err(IdentityError::unsupported_did_method(
635
"mini-doc requires Slingshot source",
636
+
)
637
+
.with_context(format!("resolving {}", did)));
638
}
639
};
640
let mut url = base;
···
690
HandleStep::HttpsWellKnown => {
691
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
692
if let Ok(text) = self.get_text(url).await {
693
+
if let Ok(did) = Self::parse_atproto_did_body(&text, handle.as_str()) {
694
resolved_did = Some(did);
695
break 'outer;
696
}
···
775
// Invalidate on error
776
#[cfg(feature = "cache")]
777
self.invalidate_handle_chain(handle).await;
778
+
Err(IdentityError::handle_resolution_exhausted()
779
+
.with_context(format!("failed to resolve handle: {}", handle)))
780
}
781
}
782
···
1093
_ => {
1094
return Err(IdentityError::unsupported_did_method(
1095
"mini-doc requires Slingshot source",
1096
+
)
1097
+
.with_context(format!("resolving {}", identifier)));
1098
}
1099
};
1100
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
···
1102
Ok(MiniDocResponse {
1103
buffer: buf,
1104
status,
1105
+
identifier: SmolStr::from(identifier.as_str()),
1106
})
1107
}
1108
}
···
1112
pub struct MiniDocResponse {
1113
buffer: Bytes,
1114
status: StatusCode,
1115
+
/// Identifier that was being resolved
1116
+
identifier: SmolStr,
1117
}
1118
1119
impl MiniDocResponse {
···
1122
if self.status.is_success() {
1123
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
1124
} else {
1125
+
Err(IdentityError::http_status(self.status)
1126
+
.with_context(format!("fetching mini-doc for {}", self.identifier)))
1127
}
1128
}
1129
}
···
1209
let resp = MiniDocResponse {
1210
buffer: buf,
1211
status: StatusCode::OK,
1212
+
identifier: SmolStr::new_static("bad-example.com"),
1213
};
1214
let doc = resp.parse().expect("parse mini-doc");
1215
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
···
1232
let resp = MiniDocResponse {
1233
buffer: buf,
1234
status: StatusCode::BAD_REQUEST,
1235
+
identifier: SmolStr::new_static("bad-example.com"),
1236
};
1237
match resp.parse() {
1238
Err(e) => match e.kind() {
+97
-15
crates/jacquard-identity/src/resolver.rs
+97
-15
crates/jacquard-identity/src/resolver.rs
···
104
extra_data: BTreeMap::new(),
105
})
106
} else {
107
-
Err(IdentityError::missing_pds_endpoint())
108
}
109
} else {
110
-
Err(IdentityError::http_status(self.status))
111
}
112
}
113
···
129
130
/// Parse as owned DidDocument<'static>
131
pub fn into_owned(self) -> Result<DidDocument<'static>> {
132
if self.status.is_success() {
133
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
134
Ok(doc.into_static())
···
150
}
151
.into_static())
152
} else {
153
-
Err(IdentityError::missing_pds_endpoint())
154
}
155
} else {
156
-
Err(IdentityError::http_status(self.status))
157
}
158
}
159
}
···
408
}
409
}
410
doc.pds_endpoint()
411
-
.ok_or_else(|| IdentityError::missing_pds_endpoint())
412
}
413
}
414
···
428
}
429
}
430
doc.pds_endpoint()
431
-
.ok_or_else(|| IdentityError::missing_pds_endpoint())
432
}
433
}
434
···
511
512
/// Error categories for identity resolution
513
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
514
pub enum IdentityErrorKind {
515
/// Unsupported DID method
516
#[error("unsupported DID method: {0}")]
···
521
UnsupportedDidMethod(SmolStr),
522
523
/// Invalid well-known atproto-did content
524
-
#[error("invalid well-known atproto-did content")]
525
#[diagnostic(
526
code(jacquard::identity::invalid_well_known),
527
help("expected first non-empty line to be a DID")
528
)]
529
-
InvalidWellKnown,
530
531
/// Missing PDS endpoint in DID document
532
-
#[error("missing PDS endpoint in DID document")]
533
#[diagnostic(
534
code(jacquard::identity::missing_pds),
535
help("ensure DID document contains AtprotoPersonalDataServer service")
536
)]
537
-
MissingPdsEndpoint,
538
539
/// Transport-level error
540
#[error("transport error")]
···
653
}
654
655
/// Create an invalid well-known error
656
-
pub fn invalid_well_known() -> Self {
657
-
Self::new(IdentityErrorKind::InvalidWellKnown, None)
658
}
659
660
/// Create a missing PDS endpoint error
661
-
pub fn missing_pds_endpoint() -> Self {
662
-
Self::new(IdentityErrorKind::MissingPdsEndpoint, None)
663
}
664
665
/// Create a transport error
···
767
768
impl From<reqwest::Error> for IdentityError {
769
fn from(e: reqwest::Error) -> Self {
770
-
Self::transport("".into(), e).with_context("HTTP request failed during identity resolution")
771
}
772
}
773
···
104
extra_data: BTreeMap::new(),
105
})
106
} else {
107
+
let did_str = self
108
+
.requested
109
+
.as_ref()
110
+
.map(|d| d.as_str())
111
+
.unwrap_or("unknown");
112
+
Err(IdentityError::missing_pds_endpoint(did_str))
113
}
114
} else {
115
+
let did_str = self
116
+
.requested
117
+
.as_ref()
118
+
.map(|d| d.as_str())
119
+
.unwrap_or("unknown");
120
+
Err(IdentityError::http_status(self.status)
121
+
.with_context(format!("fetching DID document for {}", did_str)))
122
}
123
}
124
···
140
141
/// Parse as owned DidDocument<'static>
142
pub fn into_owned(self) -> Result<DidDocument<'static>> {
143
+
let did_str = self
144
+
.requested
145
+
.as_ref()
146
+
.map(|d| SmolStr::from(d.as_str()))
147
+
.unwrap_or_else(|| SmolStr::new_static("unknown"));
148
+
149
if self.status.is_success() {
150
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
151
Ok(doc.into_static())
···
167
}
168
.into_static())
169
} else {
170
+
Err(IdentityError::missing_pds_endpoint(did_str))
171
}
172
} else {
173
+
Err(IdentityError::http_status(self.status)
174
+
.with_context(format!("fetching DID document for {}", did_str)))
175
}
176
}
177
}
···
426
}
427
}
428
doc.pds_endpoint()
429
+
.ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str()))
430
}
431
}
432
···
446
}
447
}
448
doc.pds_endpoint()
449
+
.ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str()))
450
}
451
}
452
···
529
530
/// Error categories for identity resolution
531
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
532
+
#[non_exhaustive]
533
pub enum IdentityErrorKind {
534
/// Unsupported DID method
535
#[error("unsupported DID method: {0}")]
···
540
UnsupportedDidMethod(SmolStr),
541
542
/// Invalid well-known atproto-did content
543
+
#[error("invalid well-known atproto-did content for {0}")]
544
#[diagnostic(
545
code(jacquard::identity::invalid_well_known),
546
help("expected first non-empty line to be a DID")
547
)]
548
+
InvalidWellKnown(SmolStr),
549
+
550
+
/// DNS-over-HTTPS response malformed
551
+
#[error("DNS-over-HTTPS response malformed")]
552
+
#[diagnostic(
553
+
code(jacquard::identity::doh_parse_failed),
554
+
help("DoH response missing expected JSON structure")
555
+
)]
556
+
DohParseFailed,
557
+
558
+
/// PDS fallback required but not configured
559
+
#[error("PDS fallback required but not configured")]
560
+
#[diagnostic(
561
+
code(jacquard::identity::no_pds_fallback),
562
+
help("configure pds_fallback in ResolverOptions to use PDS resolution methods")
563
+
)]
564
+
NoPdsFallback,
565
+
566
+
/// All handle resolution methods exhausted
567
+
#[error("handle resolution exhausted all configured methods")]
568
+
#[diagnostic(
569
+
code(jacquard::identity::handle_resolution_exhausted),
570
+
help("handle may not exist, or DNS/HTTPS/PDS endpoints may be unavailable")
571
+
)]
572
+
HandleResolutionExhausted,
573
574
/// Missing PDS endpoint in DID document
575
+
#[error("missing PDS endpoint in DID document for {0}")]
576
#[diagnostic(
577
code(jacquard::identity::missing_pds),
578
help("ensure DID document contains AtprotoPersonalDataServer service")
579
)]
580
+
MissingPdsEndpoint(SmolStr),
581
582
/// Transport-level error
583
#[error("transport error")]
···
696
}
697
698
/// Create an invalid well-known error
699
+
pub fn invalid_well_known(identifier: impl Into<SmolStr>) -> Self {
700
+
Self::new(IdentityErrorKind::InvalidWellKnown(identifier.into()), None)
701
+
}
702
+
703
+
/// Create an invalid well-known error with source
704
+
pub fn invalid_well_known_with_source(
705
+
identifier: impl Into<SmolStr>,
706
+
source: impl std::error::Error + Send + Sync + 'static,
707
+
) -> Self {
708
+
Self::new(
709
+
IdentityErrorKind::InvalidWellKnown(identifier.into()),
710
+
Some(Box::new(source)),
711
+
)
712
+
}
713
+
714
+
/// Create a DNS-over-HTTPS parse failed error
715
+
pub fn doh_parse_failed() -> Self {
716
+
Self::new(IdentityErrorKind::DohParseFailed, None)
717
+
}
718
+
719
+
/// Create a no PDS fallback configured error
720
+
pub fn no_pds_fallback() -> Self {
721
+
Self::new(IdentityErrorKind::NoPdsFallback, None)
722
+
}
723
+
724
+
/// Create a handle resolution exhausted error
725
+
pub fn handle_resolution_exhausted() -> Self {
726
+
Self::new(IdentityErrorKind::HandleResolutionExhausted, None)
727
}
728
729
/// Create a missing PDS endpoint error
730
+
pub fn missing_pds_endpoint(did: impl Into<SmolStr>) -> Self {
731
+
Self::new(IdentityErrorKind::MissingPdsEndpoint(did.into()), None)
732
}
733
734
/// Create a transport error
···
836
837
impl From<reqwest::Error> for IdentityError {
838
fn from(e: reqwest::Error) -> Self {
839
+
let url = e
840
+
.url()
841
+
.map(|u| smol_str::SmolStr::new(u.as_str()))
842
+
.unwrap_or_default();
843
+
Self::transport(url, e).with_context("HTTP request failed during identity resolution")
844
+
}
845
+
}
846
+
847
+
impl From<jacquard_common::error::ClientError> for IdentityError {
848
+
fn from(e: jacquard_common::error::ClientError) -> Self {
849
+
let msg = smol_str::format_smolstr!("{:?}", e.kind());
850
+
let mut err = Self::new(IdentityErrorKind::Xrpc(msg), Some(Box::new(e)));
851
+
err = err.with_context("XRPC call failed during identity resolution");
852
+
err
853
}
854
}
855
+24
-88
crates/jacquard-lexicon/src/error.rs
+24
-88
crates/jacquard-lexicon/src/error.rs
···
5
6
/// Errors that can occur during lexicon code generation
7
#[derive(Debug, Error, Diagnostic)]
8
pub enum CodegenError {
9
/// IO error when reading lexicon files
10
#[error("IO error: {0}")]
···
29
span: Option<SourceSpan>,
30
},
31
32
-
/// Reference to non-existent lexicon or def
33
-
#[error("Reference to unknown type: {ref_string}")]
34
-
#[diagnostic(
35
-
code(lexicon::unknown_ref),
36
-
help("Add the referenced lexicon to your corpus or use Data<'a> as a fallback type")
37
-
)]
38
-
UnknownRef {
39
-
/// The ref string that couldn't be resolved
40
-
ref_string: String,
41
-
/// NSID of lexicon containing the ref
42
-
lexicon_nsid: String,
43
-
/// Def name containing the ref
44
-
def_name: String,
45
-
/// Field path containing the ref
46
-
field_path: String,
47
-
},
48
-
49
-
/// Circular reference detected in type definitions
50
-
#[error("Circular reference detected")]
51
-
#[diagnostic(
52
-
code(lexicon::circular_ref),
53
-
help("The code generator uses Box<T> for union variants to handle circular references")
54
-
)]
55
-
CircularRef {
56
-
/// The ref string that forms a cycle
57
-
ref_string: String,
58
-
/// The cycle path
59
-
cycle: Vec<String>,
60
-
},
61
-
62
-
/// Invalid lexicon structure
63
-
#[error("Invalid lexicon: {message}")]
64
-
#[diagnostic(code(lexicon::invalid))]
65
-
InvalidLexicon {
66
-
message: String,
67
-
/// NSID of the invalid lexicon
68
-
lexicon_nsid: String,
69
-
},
70
-
71
-
/// Unsupported lexicon feature
72
-
#[error("Unsupported feature: {feature}")]
73
-
#[diagnostic(
74
-
code(lexicon::unsupported),
75
-
help("This lexicon feature is not yet supported by the code generator")
76
-
)]
77
-
Unsupported {
78
-
/// Description of the unsupported feature
79
-
#[allow(unused)]
80
-
feature: String,
81
-
/// NSID of lexicon containing the feature
82
-
#[allow(unused)]
83
-
lexicon_nsid: String,
84
-
/// Optional suggestion for workaround
85
-
#[allow(unused)]
86
-
suggestion: Option<String>,
87
-
},
88
-
89
/// Name collision
90
#[error("Name collision: {name}")]
91
#[diagnostic(
···
119
tokens: String,
120
},
121
122
-
/// Failed to parse module path string
123
-
#[error("Failed to parse module path: {path_str}")]
124
#[diagnostic(code(lexicon::path_parse_error))]
125
PathParseError {
126
path_str: String,
···
163
}
164
}
165
166
-
/// Create an unknown ref error
167
-
pub fn unknown_ref(
168
-
ref_string: impl Into<String>,
169
-
lexicon_nsid: impl Into<String>,
170
-
def_name: impl Into<String>,
171
-
field_path: impl Into<String>,
172
-
) -> Self {
173
-
Self::UnknownRef {
174
-
ref_string: ref_string.into(),
175
-
lexicon_nsid: lexicon_nsid.into(),
176
-
def_name: def_name.into(),
177
-
field_path: field_path.into(),
178
-
}
179
-
}
180
-
181
-
/// Create an invalid lexicon error
182
-
pub fn invalid_lexicon(message: impl Into<String>, lexicon_nsid: impl Into<String>) -> Self {
183
-
Self::InvalidLexicon {
184
-
message: message.into(),
185
-
lexicon_nsid: lexicon_nsid.into(),
186
-
}
187
-
}
188
-
189
/// Create an unsupported feature error
190
pub fn unsupported(
191
-
feature: impl Into<String>,
192
-
lexicon_nsid: impl Into<String>,
193
-
suggestion: Option<impl Into<String>>,
194
) -> Self {
195
Self::Unsupported {
196
-
feature: feature.into(),
197
-
lexicon_nsid: lexicon_nsid.into(),
198
-
suggestion: suggestion.map(|s| s.into()),
199
}
200
}
201
}
···
5
6
/// Errors that can occur during lexicon code generation
7
#[derive(Debug, Error, Diagnostic)]
8
+
#[non_exhaustive]
9
pub enum CodegenError {
10
/// IO error when reading lexicon files
11
#[error("IO error: {0}")]
···
30
span: Option<SourceSpan>,
31
},
32
33
/// Name collision
34
#[error("Name collision: {name}")]
35
#[diagnostic(
···
63
tokens: String,
64
},
65
66
+
/// Unsupported lexicon feature
67
+
#[error("Unsupported: {message}")]
68
+
#[diagnostic(
69
+
code(lexicon::unsupported),
70
+
help("This lexicon feature is not yet supported by code generation")
71
+
)]
72
+
Unsupported {
73
+
/// Description of the unsupported feature
74
+
message: String,
75
+
/// NSID of the lexicon containing the unsupported feature
76
+
nsid: Option<String>,
77
+
/// Definition name if applicable
78
+
def_name: Option<String>,
79
+
},
80
+
81
+
/// Failed to parse generated path string
82
+
#[error("Failed to parse path '{path_str}'")]
83
#[diagnostic(code(lexicon::path_parse_error))]
84
PathParseError {
85
path_str: String,
···
122
}
123
}
124
125
/// Create an unsupported feature error
126
pub fn unsupported(
127
+
message: impl Into<String>,
128
+
nsid: impl Into<String>,
129
+
def_name: Option<impl Into<String>>,
130
) -> Self {
131
Self::Unsupported {
132
+
message: message.into(),
133
+
nsid: Some(nsid.into()),
134
+
def_name: def_name.map(|s| s.into()),
135
}
136
}
137
}
+4
crates/jacquard-lexicon/src/validation.rs
+4
crates/jacquard-lexicon/src/validation.rs
···
113
///
114
/// These errors indicate that the data structure doesn't match the schema's type expectations.
115
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
116
pub enum StructuralError {
117
#[error("Type mismatch at {path}: expected {expected}, got {actual}")]
118
TypeMismatch {
···
159
/// These errors indicate that the data violates lexicon constraints like max_length,
160
/// max_graphemes, ranges, etc. The structure is correct but values are out of bounds.
161
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
162
pub enum ConstraintError {
163
#[error("{path} exceeds max length: {actual} > {max}")]
164
MaxLength {
···
205
206
/// Unified validation error type
207
#[derive(Debug, Clone, thiserror::Error)]
208
pub enum ValidationError {
209
#[error(transparent)]
210
Structural(#[from] StructuralError),
···
239
240
/// Errors that can occur when computing CIDs
241
#[derive(Debug, thiserror::Error)]
242
pub enum CidComputationError {
243
#[error("Failed to serialize data to DAG-CBOR: {0}")]
244
DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
···
113
///
114
/// These errors indicate that the data structure doesn't match the schema's type expectations.
115
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
116
+
#[non_exhaustive]
117
pub enum StructuralError {
118
#[error("Type mismatch at {path}: expected {expected}, got {actual}")]
119
TypeMismatch {
···
160
/// These errors indicate that the data violates lexicon constraints like max_length,
161
/// max_graphemes, ranges, etc. The structure is correct but values are out of bounds.
162
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
163
+
#[non_exhaustive]
164
pub enum ConstraintError {
165
#[error("{path} exceeds max length: {actual} > {max}")]
166
MaxLength {
···
207
208
/// Unified validation error type
209
#[derive(Debug, Clone, thiserror::Error)]
210
+
#[non_exhaustive]
211
pub enum ValidationError {
212
#[error(transparent)]
213
Structural(#[from] StructuralError),
···
242
243
/// Errors that can occur when computing CIDs
244
#[derive(Debug, thiserror::Error)]
245
+
#[non_exhaustive]
246
pub enum CidComputationError {
247
#[error("Failed to serialize data to DAG-CBOR: {0}")]
248
DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
+2
crates/jacquard-oauth/src/atproto.rs
+2
crates/jacquard-oauth/src/atproto.rs
···
10
use url::Url;
11
12
#[derive(Error, Debug)]
13
+
#[non_exhaustive]
14
pub enum Error {
15
#[error("`client_id` must be a valid URL")]
16
InvalidClientId,
···
33
}
34
35
#[derive(Error, Debug)]
36
+
#[non_exhaustive]
37
pub enum LocalhostClientError {
38
#[error("invalid redirect_uri: {0}")]
39
Invalid(#[from] url::ParseError),
+6
-2
crates/jacquard-oauth/src/client.rs
+6
-2
crates/jacquard-oauth/src/client.rs
···
625
.dpop_call(&mut dpop)
626
.send(build_http_request(&base_uri, &request, &opts)?)
627
.await
628
-
.map_err(|e| ClientError::transport(e))?;
629
let resp = process_response(http_response);
630
631
// Write back updated nonce to session data (dpop_call may have updated it)
···
655
.dpop_call(&mut dpop)
656
.send(build_http_request(&base_uri, &request, &opts)?)
657
.await
658
-
.map_err(|e| ClientError::transport(e))?;
659
let resp = process_response(http_response);
660
661
// Write back updated nonce after retry
···
625
.dpop_call(&mut dpop)
626
.send(build_http_request(&base_uri, &request, &opts)?)
627
.await
628
+
.map_err(|e| ClientError::from(e).for_nsid(R::NSID))?;
629
let resp = process_response(http_response);
630
631
// Write back updated nonce to session data (dpop_call may have updated it)
···
655
.dpop_call(&mut dpop)
656
.send(build_http_request(&base_uri, &request, &opts)?)
657
.await
658
+
.map_err(|e| {
659
+
ClientError::from(e)
660
+
.for_nsid(R::NSID)
661
+
.append_context("after token refresh")
662
+
})?;
663
let resp = process_response(http_response);
664
665
// Write back updated nonce after retry
+327
-20
crates/jacquard-oauth/src/dpop.rs
+327
-20
crates/jacquard-oauth/src/dpop.rs
···
1
use std::future::Future;
2
3
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
···
10
use p256::ecdsa::SigningKey;
11
use rand::{RngCore, SeedableRng};
12
use sha2::Digest;
13
14
use crate::{
15
jose::{
···
27
error: String,
28
}
29
30
-
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
31
-
pub enum Error {
32
-
#[error(transparent)]
33
-
InvalidHeaderValue(#[from] InvalidHeaderValue),
34
-
#[error("crypto error: {0:?}")]
35
-
JwkCrypto(crypto::Error),
36
-
#[error("key does not match any alg supported by the server")]
37
UnsupportedKey,
38
-
#[error(transparent)]
39
-
SerdeJson(#[from] serde_json::Error),
40
-
#[error("Inner: {0}")]
41
-
Inner(#[source] Box<dyn std::error::Error + Send + Sync>),
42
}
43
44
-
type Result<T> = core::result::Result<T, Error>;
45
46
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
47
pub trait DpopClient: HttpClient {
···
191
T: HttpClient,
192
N: DpopDataSource,
193
{
194
let uri = request.uri().clone();
195
let method = request.method().to_cowstr().into_static();
196
let uri = uri.to_cowstr();
197
let ath = extract_ath(request.headers());
198
···
208
let response = client
209
.send_http(request.clone())
210
.await
211
-
.map_err(|e| Error::Inner(e.into()))?;
212
213
let next_nonce = response
214
.headers()
···
232
let response = client
233
.send_http(request)
234
.await
235
-
.map_err(|e| Error::Inner(e.into()))?;
236
Ok(response)
237
}
238
···
249
{
250
use jacquard_common::xrpc::StreamingResponse;
251
252
let uri = request.uri().clone();
253
let method = request.method().to_cowstr().into_static();
254
let uri = uri.to_cowstr();
255
let ath = extract_ath(request.headers());
256
···
266
let http_response = client
267
.send_http_streaming(request.clone())
268
.await
269
-
.map_err(|e| Error::Inner(e.into()))?;
270
271
let (parts, body) = http_response.into_parts();
272
let next_nonce = parts
···
294
let http_response = client
295
.send_http_streaming(request)
296
.await
297
-
.map_err(|e| Error::Inner(e.into()))?;
298
let (parts, body) = http_response.into_parts();
299
Ok(StreamingResponse::new(parts, body))
300
}
···
313
{
314
use jacquard_common::xrpc::StreamingResponse;
315
316
let uri = parts.uri.clone();
317
let method = parts.method.to_cowstr().into_static();
318
let uri = uri.to_cowstr();
319
let ath = extract_ath(&parts.headers);
320
···
334
let http_response = client
335
.send_http_bidirectional(parts.clone(), body1.into_inner())
336
.await
337
-
.map_err(|e| Error::Inner(e.into()))?;
338
339
let (resp_parts, resp_body) = http_response.into_parts();
340
let next_nonce = resp_parts
···
363
let http_response = client
364
.send_http_bidirectional(parts, body2.into_inner())
365
.await
366
-
.map_err(|e| Error::Inner(e.into()))?;
367
let (parts, body) = http_response.into_parts();
368
Ok(StreamingResponse::new(parts, body))
369
}
···
430
nonce: Option<CowStr<'s>>,
431
ath: Option<CowStr<'s>>,
432
) -> Result<CowStr<'s>> {
433
-
let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? {
434
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
435
-
_ => return Err(Error::UnsupportedKey),
436
};
437
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
438
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
···
1
+
use std::error::Error as StdError;
2
+
use std::fmt;
3
use std::future::Future;
4
5
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
···
12
use p256::ecdsa::SigningKey;
13
use rand::{RngCore, SeedableRng};
14
use sha2::Digest;
15
+
use smol_str::SmolStr;
16
17
use crate::{
18
jose::{
···
30
error: String,
31
}
32
33
+
/// Boxed error type for error sources.
34
+
pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
35
+
36
+
/// Target server type for DPoP requests.
37
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+
pub enum DpopTarget {
39
+
/// OAuth authorization server (token endpoint, PAR, etc.)
40
+
AuthServer,
41
+
/// Resource server (PDS, AppView, etc.)
42
+
ResourceServer,
43
+
}
44
+
45
+
impl fmt::Display for DpopTarget {
46
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47
+
match self {
48
+
DpopTarget::AuthServer => write!(f, "auth server"),
49
+
DpopTarget::ResourceServer => write!(f, "resource server"),
50
+
}
51
+
}
52
+
}
53
+
54
+
/// Error categories for DPoP operations.
55
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56
+
#[non_exhaustive]
57
+
pub enum DpopErrorKind {
58
+
/// DPoP proof construction failed.
59
+
ProofBuild,
60
+
/// Initial HTTP request failed.
61
+
Transport,
62
+
/// Retry after nonce update also failed.
63
+
NonceRetry,
64
+
/// Header value parsing failed.
65
+
InvalidHeader,
66
+
/// JWK crypto operation failed.
67
+
Crypto,
68
+
/// Key type not supported for DPoP.
69
UnsupportedKey,
70
+
/// JSON serialization failed.
71
+
Serialization,
72
+
}
73
+
74
+
impl fmt::Display for DpopErrorKind {
75
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76
+
match self {
77
+
DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"),
78
+
DpopErrorKind::Transport => write!(f, "HTTP request failed"),
79
+
DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"),
80
+
DpopErrorKind::InvalidHeader => write!(f, "invalid header value"),
81
+
DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"),
82
+
DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"),
83
+
DpopErrorKind::Serialization => write!(f, "JSON serialization failed"),
84
+
}
85
+
}
86
+
}
87
+
88
+
/// DPoP operation error with rich context.
89
+
#[derive(Debug, miette::Diagnostic)]
90
+
pub struct DpopError {
91
+
kind: DpopErrorKind,
92
+
target: Option<DpopTarget>,
93
+
url: Option<SmolStr>,
94
+
source: Option<BoxError>,
95
+
context: Option<SmolStr>,
96
+
#[help]
97
+
help: Option<&'static str>,
98
+
}
99
+
100
+
impl fmt::Display for DpopError {
101
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102
+
write!(f, "{}", self.kind)?;
103
+
104
+
if let Some(target) = &self.target {
105
+
write!(f, " (to {})", target)?;
106
+
}
107
+
108
+
if let Some(url) = &self.url {
109
+
write!(f, " [{}]", url)?;
110
+
}
111
+
112
+
if let Some(ctx) = &self.context {
113
+
write!(f, ": {}", ctx)?;
114
+
}
115
+
116
+
Ok(())
117
+
}
118
+
}
119
+
120
+
impl StdError for DpopError {
121
+
fn source(&self) -> Option<&(dyn StdError + 'static)> {
122
+
self.source
123
+
.as_ref()
124
+
.map(|e| e.as_ref() as &(dyn StdError + 'static))
125
+
}
126
+
}
127
+
128
+
impl DpopError {
129
+
/// Create a new error with the given kind.
130
+
fn new(kind: DpopErrorKind) -> Self {
131
+
Self {
132
+
kind,
133
+
target: None,
134
+
url: None,
135
+
source: None,
136
+
context: None,
137
+
help: None,
138
+
}
139
+
}
140
+
141
+
/// Get the error kind.
142
+
pub fn kind(&self) -> DpopErrorKind {
143
+
self.kind
144
+
}
145
+
146
+
/// Get the target server type, if known.
147
+
pub fn target(&self) -> Option<DpopTarget> {
148
+
self.target
149
+
}
150
+
151
+
/// Get the URL, if known.
152
+
pub fn url(&self) -> Option<&str> {
153
+
self.url.as_deref()
154
+
}
155
+
156
+
/// Get the context string, if any.
157
+
pub fn context(&self) -> Option<&str> {
158
+
self.context.as_deref()
159
+
}
160
+
161
+
// Builder methods
162
+
163
+
fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
164
+
self.source = Some(Box::new(source));
165
+
self
166
+
}
167
+
168
+
fn with_target(mut self, target: DpopTarget) -> Self {
169
+
self.target = Some(target);
170
+
self
171
+
}
172
+
173
+
fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
174
+
self.url = Some(url.into());
175
+
self
176
+
}
177
+
178
+
fn with_help(mut self, help: &'static str) -> Self {
179
+
self.help = Some(help);
180
+
self
181
+
}
182
+
183
+
/// Add context information to the error.
184
+
pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
185
+
self.context = Some(context.into());
186
+
self
187
+
}
188
+
189
+
/// Append additional context to the error.
190
+
pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
191
+
self.context = Some(match self.context.take() {
192
+
Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
193
+
None => SmolStr::new(additional.as_ref()),
194
+
});
195
+
self
196
+
}
197
+
198
+
/// Add NSID context (for use by higher-level code).
199
+
pub fn for_nsid(self, nsid: &str) -> Self {
200
+
self.append_context(smol_str::format_smolstr!("[{}]", nsid))
201
+
}
202
+
203
+
// Constructors for specific error kinds
204
+
205
+
/// Create a proof build error.
206
+
pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self {
207
+
Self::new(DpopErrorKind::ProofBuild)
208
+
.with_source(source)
209
+
.with_help("check that the DPoP key is valid and the JWT claims are correct")
210
+
}
211
+
212
+
/// Create a transport error for initial request.
213
+
pub fn transport(
214
+
target: DpopTarget,
215
+
url: impl Into<SmolStr>,
216
+
source: impl StdError + Send + Sync + 'static,
217
+
) -> Self {
218
+
Self::new(DpopErrorKind::Transport)
219
+
.with_target(target)
220
+
.with_url(url)
221
+
.with_source(source)
222
+
}
223
+
224
+
/// Create a nonce retry error.
225
+
pub fn nonce_retry(
226
+
target: DpopTarget,
227
+
url: impl Into<SmolStr>,
228
+
source: impl StdError + Send + Sync + 'static,
229
+
) -> Self {
230
+
Self::new(DpopErrorKind::NonceRetry)
231
+
.with_target(target)
232
+
.with_url(url)
233
+
.with_source(source)
234
+
.with_help(
235
+
"the server rejected both the initial request and the retry with updated nonce",
236
+
)
237
+
}
238
+
239
+
/// Create an invalid header error.
240
+
pub fn invalid_header(source: InvalidHeaderValue) -> Self {
241
+
Self::new(DpopErrorKind::InvalidHeader)
242
+
.with_source(source)
243
+
.with_help("the DPoP proof could not be set as a header value")
244
+
}
245
+
246
+
/// Create a crypto error.
247
+
pub fn crypto(source: crypto::Error) -> Self {
248
+
Self::new(DpopErrorKind::Crypto)
249
+
.with_context(format!("{:?}", source))
250
+
.with_help(
251
+
"ensure the key is a valid secret key in JWK format with a supported algorithm",
252
+
)
253
+
}
254
+
255
+
/// Create an unsupported key error.
256
+
pub fn unsupported_key() -> Self {
257
+
Self::new(DpopErrorKind::UnsupportedKey)
258
+
.with_help("DPoP requires an EC P-256 key; other key types are not currently supported")
259
+
}
260
+
261
+
/// Create a serialization error.
262
+
pub fn serialization(source: serde_json::Error) -> Self {
263
+
Self::new(DpopErrorKind::Serialization)
264
+
.with_source(source)
265
+
.with_help("failed to serialize JWT claims or header")
266
+
}
267
+
}
268
+
269
+
impl From<InvalidHeaderValue> for DpopError {
270
+
fn from(e: InvalidHeaderValue) -> Self {
271
+
Self::invalid_header(e)
272
+
}
273
+
}
274
+
275
+
impl From<serde_json::Error> for DpopError {
276
+
fn from(e: serde_json::Error) -> Self {
277
+
Self::serialization(e)
278
+
}
279
+
}
280
+
281
+
impl From<DpopError> for jacquard_common::error::ClientError {
282
+
fn from(e: DpopError) -> Self {
283
+
use jacquard_common::error::{AuthError, ClientError};
284
+
285
+
// Extract context from DpopError before converting
286
+
let kind = e.kind;
287
+
let url = e.url.clone();
288
+
let context = e.context.clone();
289
+
let target = e.target;
290
+
291
+
// Build combined context string
292
+
let combined_context = match (target, context) {
293
+
(Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)),
294
+
(Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)),
295
+
(None, Some(c)) => Some(c),
296
+
(None, None) => None,
297
+
};
298
+
299
+
// Map DpopErrorKind to appropriate ClientError
300
+
let mut client_err = match kind {
301
+
DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => {
302
+
ClientError::auth(AuthError::DpopProofFailed)
303
+
}
304
+
DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed),
305
+
DpopErrorKind::Transport => ClientError::new(
306
+
jacquard_common::error::ClientErrorKind::Transport,
307
+
Some(Box::new(e)),
308
+
),
309
+
DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => {
310
+
let msg = smol_str::format_smolstr!("DPoP: {:?}", kind);
311
+
ClientError::encode(msg)
312
+
}
313
+
};
314
+
315
+
// Add URL if present (skip for Transport since e was consumed)
316
+
if !matches!(kind, DpopErrorKind::Transport) {
317
+
if let Some(u) = url {
318
+
client_err = client_err.with_url(u);
319
+
}
320
+
}
321
+
322
+
// Add combined context if present (skip for Transport since e was consumed)
323
+
if !matches!(kind, DpopErrorKind::Transport) {
324
+
if let Some(ctx) = combined_context {
325
+
client_err = client_err.with_context(ctx);
326
+
}
327
+
}
328
+
329
+
client_err
330
+
}
331
}
332
333
+
type Result<T> = core::result::Result<T, DpopError>;
334
335
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
336
pub trait DpopClient: HttpClient {
···
480
T: HttpClient,
481
N: DpopDataSource,
482
{
483
+
let target = if is_to_auth_server {
484
+
DpopTarget::AuthServer
485
+
} else {
486
+
DpopTarget::ResourceServer
487
+
};
488
let uri = request.uri().clone();
489
let method = request.method().to_cowstr().into_static();
490
+
let url_str: SmolStr = uri.to_cowstr().as_ref().into();
491
let uri = uri.to_cowstr();
492
let ath = extract_ath(request.headers());
493
···
503
let response = client
504
.send_http(request.clone())
505
.await
506
+
.map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
507
508
let next_nonce = response
509
.headers()
···
527
let response = client
528
.send_http(request)
529
.await
530
+
.map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
531
Ok(response)
532
}
533
···
544
{
545
use jacquard_common::xrpc::StreamingResponse;
546
547
+
let target = if is_to_auth_server {
548
+
DpopTarget::AuthServer
549
+
} else {
550
+
DpopTarget::ResourceServer
551
+
};
552
let uri = request.uri().clone();
553
let method = request.method().to_cowstr().into_static();
554
+
let url_str: SmolStr = uri.to_cowstr().as_ref().into();
555
let uri = uri.to_cowstr();
556
let ath = extract_ath(request.headers());
557
···
567
let http_response = client
568
.send_http_streaming(request.clone())
569
.await
570
+
.map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
571
572
let (parts, body) = http_response.into_parts();
573
let next_nonce = parts
···
595
let http_response = client
596
.send_http_streaming(request)
597
.await
598
+
.map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
599
let (parts, body) = http_response.into_parts();
600
Ok(StreamingResponse::new(parts, body))
601
}
···
614
{
615
use jacquard_common::xrpc::StreamingResponse;
616
617
+
let target = if is_to_auth_server {
618
+
DpopTarget::AuthServer
619
+
} else {
620
+
DpopTarget::ResourceServer
621
+
};
622
let uri = parts.uri.clone();
623
let method = parts.method.to_cowstr().into_static();
624
+
let url_str: SmolStr = uri.to_cowstr().as_ref().into();
625
let uri = uri.to_cowstr();
626
let ath = extract_ath(&parts.headers);
627
···
641
let http_response = client
642
.send_http_bidirectional(parts.clone(), body1.into_inner())
643
.await
644
+
.map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
645
646
let (resp_parts, resp_body) = http_response.into_parts();
647
let next_nonce = resp_parts
···
670
let http_response = client
671
.send_http_bidirectional(parts, body2.into_inner())
672
.await
673
+
.map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
674
let (parts, body) = http_response.into_parts();
675
Ok(StreamingResponse::new(parts, body))
676
}
···
737
nonce: Option<CowStr<'s>>,
738
ath: Option<CowStr<'s>>,
739
) -> Result<CowStr<'s>> {
740
+
let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? {
741
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
742
+
_ => return Err(DpopError::unsupported_key()),
743
};
744
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
745
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
+3
-1
crates/jacquard-oauth/src/error.rs
+3
-1
crates/jacquard-oauth/src/error.rs
···
6
7
/// High-level errors emitted by OAuth helpers.
8
#[derive(Debug, thiserror::Error, Diagnostic)]
9
pub enum OAuthError {
10
#[error(transparent)]
11
#[diagnostic(code(jacquard_oauth::resolver))]
···
21
22
#[error(transparent)]
23
#[diagnostic(code(jacquard_oauth::dpop))]
24
-
Dpop(#[from] crate::dpop::Error),
25
26
#[error(transparent)]
27
#[diagnostic(code(jacquard_oauth::keyset))]
···
54
55
/// Typed callback validation errors (redirect handling).
56
#[derive(Debug, thiserror::Error, Diagnostic)]
57
pub enum CallbackError {
58
#[error("missing state parameter in callback")]
59
#[diagnostic(code(jacquard_oauth::callback::missing_state))]
···
6
7
/// High-level errors emitted by OAuth helpers.
8
#[derive(Debug, thiserror::Error, Diagnostic)]
9
+
#[non_exhaustive]
10
pub enum OAuthError {
11
#[error(transparent)]
12
#[diagnostic(code(jacquard_oauth::resolver))]
···
22
23
#[error(transparent)]
24
#[diagnostic(code(jacquard_oauth::dpop))]
25
+
Dpop(#[from] crate::dpop::DpopError),
26
27
#[error(transparent)]
28
#[diagnostic(code(jacquard_oauth::keyset))]
···
55
56
/// Typed callback validation errors (redirect handling).
57
#[derive(Debug, thiserror::Error, Diagnostic)]
58
+
#[non_exhaustive]
59
pub enum CallbackError {
60
#[error("missing state parameter in callback")]
61
#[diagnostic(code(jacquard_oauth::callback::missing_state))]
+5
-4
crates/jacquard-oauth/src/keyset.rs
+5
-4
crates/jacquard-oauth/src/keyset.rs
···
9
use thiserror::Error;
10
11
#[derive(Error, Debug)]
12
pub enum Error {
13
#[error("duplicate kid: {0}")]
14
DuplicateKid(String),
15
#[error("keys must not be empty")]
16
EmptyKeys,
17
-
#[error("key must have a `kid`")]
18
-
EmptyKid,
19
#[error("no signing key found for algorithms: {0:?}")]
20
NotFound(Vec<CowStr<'static>>),
21
#[error("key for signing must be a secret key")]
···
103
}
104
let mut v = Vec::with_capacity(keys.len());
105
let mut hs = HashSet::with_capacity(keys.len());
106
-
for key in keys {
107
if let Some(kid) = key.prm.kid.clone() {
108
if hs.contains(&kid) {
109
return Err(Error::DuplicateKid(kid));
···
119
}
120
v.push(key);
121
} else {
122
-
return Err(Error::EmptyKid);
123
}
124
}
125
Ok(Self(v))
···
9
use thiserror::Error;
10
11
#[derive(Error, Debug)]
12
+
#[non_exhaustive]
13
pub enum Error {
14
#[error("duplicate kid: {0}")]
15
DuplicateKid(String),
16
#[error("keys must not be empty")]
17
EmptyKeys,
18
+
#[error("key at index {0} must have a `kid`")]
19
+
EmptyKid(usize),
20
#[error("no signing key found for algorithms: {0:?}")]
21
NotFound(Vec<CowStr<'static>>),
22
#[error("key for signing must be a secret key")]
···
104
}
105
let mut v = Vec::with_capacity(keys.len());
106
let mut hs = HashSet::with_capacity(keys.len());
107
+
for (i, key) in keys.into_iter().enumerate() {
108
if let Some(kid) = key.prm.kid.clone() {
109
if hs.contains(&kid) {
110
return Err(Error::DuplicateKid(kid));
···
120
}
121
v.push(key);
122
} else {
123
+
return Err(Error::EmptyKid(i));
124
}
125
}
126
Ok(Self(v))
+3
-2
crates/jacquard-oauth/src/request.rs
+3
-2
crates/jacquard-oauth/src/request.rs
···
61
62
/// Error categories for OAuth request operations
63
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
64
pub enum RequestErrorKind {
65
/// No endpoint available
66
#[error("no {0} endpoint available")]
···
331
}
332
}
333
334
-
impl From<crate::dpop::Error> for RequestError {
335
-
fn from(e: crate::dpop::Error) -> Self {
336
let msg = smol_str::format_smolstr!("{:?}", e);
337
Self::new(RequestErrorKind::Dpop, Some(Box::new(e)))
338
.with_context(msg)
···
61
62
/// Error categories for OAuth request operations
63
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
64
+
#[non_exhaustive]
65
pub enum RequestErrorKind {
66
/// No endpoint available
67
#[error("no {0} endpoint available")]
···
332
}
333
}
334
335
+
impl From<crate::dpop::DpopError> for RequestError {
336
+
fn from(e: crate::dpop::DpopError) -> Self {
337
let msg = smol_str::format_smolstr!("{:?}", e);
338
Self::new(RequestErrorKind::Dpop, Some(Box::new(e)))
339
.with_context(msg)
+1
crates/jacquard-oauth/src/resolver.rs
+1
crates/jacquard-oauth/src/resolver.rs
+1
crates/jacquard-oauth/src/scopes.rs
+1
crates/jacquard-oauth/src/scopes.rs
+1
crates/jacquard-oauth/src/session.rs
+1
crates/jacquard-oauth/src/session.rs
+11
crates/jacquard-repo/src/error.rs
+11
crates/jacquard-repo/src/error.rs
···
22
23
/// Error categories for repository operations
24
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25
pub enum RepoErrorKind {
26
/// Storage operation failed
27
Storage,
···
145
Self::new(RepoErrorKind::Car, Some(Box::new(source)))
146
}
147
148
/// Create a CAR parse error (alias for car)
149
pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self {
150
Self::car(source).with_context("Failed to parse CAR file".to_string())
···
207
208
/// MST-specific errors
209
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
210
pub enum MstError {
211
/// Empty key not allowed
212
#[error("Empty key not allowed")]
···
253
254
/// Commit-specific errors
255
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
256
pub enum CommitError {
257
/// Invalid commit version
258
#[error("Invalid commit version: {0}")]
···
302
303
/// Diff-specific errors
304
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
305
pub enum DiffError {
306
/// Too many operations
307
#[error("Too many operations: {count} (max {max})")]
···
335
336
/// Proof verification errors
337
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
338
pub enum ProofError {
339
/// CAR file has no root CID
340
#[error("CAR file has no root CID")]
···
22
23
/// Error categories for repository operations
24
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25
+
#[non_exhaustive]
26
pub enum RepoErrorKind {
27
/// Storage operation failed
28
Storage,
···
146
Self::new(RepoErrorKind::Car, Some(Box::new(source)))
147
}
148
149
+
/// Create a CAR write error with CID context
150
+
pub fn car_write(cid: impl fmt::Display, source: impl Error + Send + Sync + 'static) -> Self {
151
+
Self::new(RepoErrorKind::Car, Some(Box::new(source)))
152
+
.with_context(format!("failed to write block {}", cid))
153
+
}
154
+
155
/// Create a CAR parse error (alias for car)
156
pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self {
157
Self::car(source).with_context("Failed to parse CAR file".to_string())
···
214
215
/// MST-specific errors
216
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
217
+
#[non_exhaustive]
218
pub enum MstError {
219
/// Empty key not allowed
220
#[error("Empty key not allowed")]
···
261
262
/// Commit-specific errors
263
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
264
+
#[non_exhaustive]
265
pub enum CommitError {
266
/// Invalid commit version
267
#[error("Invalid commit version: {0}")]
···
311
312
/// Diff-specific errors
313
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
314
+
#[non_exhaustive]
315
pub enum DiffError {
316
/// Too many operations
317
#[error("Too many operations: {count} (max {max})")]
···
345
346
/// Proof verification errors
347
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
348
+
#[non_exhaustive]
349
pub enum ProofError {
350
/// CAR file has no root CID
351
#[error("CAR file has no root CID")]
+22
-12
crates/jacquard-repo/src/mst/tree.rs
+22
-12
crates/jacquard-repo/src/mst/tree.rs
···
1262
entries = self.get_entries().await?
1263
}
1264
let data = util::serialize_node_data(entries.as_slice()).await?;
1265
-
let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| RepoError::car(e))?;
1266
let cid = util::compute_cid(&bytes)?;
1267
1268
Ok((cid, Bytes::from_owner(bytes)))
···
1336
writer
1337
.write(*cid, &data)
1338
.await
1339
-
.map_err(|e| RepoError::car(e))?;
1340
}
1341
}
1342
···
1364
writer
1365
.write(pointer, &node_bytes)
1366
.await
1367
-
.map_err(|e| RepoError::car(e))?;
1368
1369
// Parse to get entries
1370
let entries = self.get_entries().await?;
···
1411
.collect();
1412
1413
// Await all tasks concurrently
1414
-
let results = try_join_all(tasks)
1415
-
.await
1416
-
.map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?;
1417
1418
// Flatten results
1419
let mut cids = vec![pointer];
···
1455
1456
// Await all tasks concurrently
1457
if !tasks.is_empty() {
1458
-
let subtree_results = try_join_all(tasks)
1459
-
.await
1460
-
.map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?;
1461
1462
for (pos, leaves) in task_positions.into_iter().zip(subtree_results) {
1463
result.push((pos, leaves?));
···
1518
1519
// Await all tasks concurrently
1520
if !tasks.is_empty() {
1521
-
let results = try_join_all(tasks)
1522
-
.await
1523
-
.map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?;
1524
1525
for subtree_result in results {
1526
let (_, subtree_blocks) = subtree_result?;
···
1262
entries = self.get_entries().await?
1263
}
1264
let data = util::serialize_node_data(entries.as_slice()).await?;
1265
+
let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| {
1266
+
// Provide best available context for debugging serialization failures
1267
+
let context = if let Some(left_cid) = &data.left {
1268
+
format!("serializing MST node (left pointer: {})", left_cid)
1269
+
} else {
1270
+
// No left pointer - use last leaf key as identifier
1271
+
let last_key = entries.iter().rev().find_map(|e| match e {
1272
+
NodeEntry::Leaf { key, .. } => Some(key.as_str()),
1273
+
_ => None,
1274
+
});
1275
+
match last_key {
1276
+
Some(k) => format!("serializing MST node (last key: {})", k),
1277
+
None => "serializing MST node (empty or tree-only)".to_string(),
1278
+
}
1279
+
};
1280
+
RepoError::serialization(e).with_context(context)
1281
+
})?;
1282
let cid = util::compute_cid(&bytes)?;
1283
1284
Ok((cid, Bytes::from_owner(bytes)))
···
1352
writer
1353
.write(*cid, &data)
1354
.await
1355
+
.map_err(|e| RepoError::car_write(cid, e))?;
1356
}
1357
}
1358
···
1380
writer
1381
.write(pointer, &node_bytes)
1382
.await
1383
+
.map_err(|e| RepoError::car_write(&pointer, e))?;
1384
1385
// Parse to get entries
1386
let entries = self.get_entries().await?;
···
1427
.collect();
1428
1429
// Await all tasks concurrently
1430
+
let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?;
1431
1432
// Flatten results
1433
let mut cids = vec![pointer];
···
1469
1470
// Await all tasks concurrently
1471
if !tasks.is_empty() {
1472
+
let subtree_results = try_join_all(tasks).await.map_err(RepoError::task_failed)?;
1473
1474
for (pos, leaves) in task_positions.into_iter().zip(subtree_results) {
1475
result.push((pos, leaves?));
···
1530
1531
// Await all tasks concurrently
1532
if !tasks.is_empty() {
1533
+
let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?;
1534
1535
for subtree_result in results {
1536
let (_, subtree_blocks) = subtree_result?;
+45
-35
crates/jacquard/src/client.rs
+45
-35
crates/jacquard/src/client.rs
···
674
let (did, _) = self
675
.session_info()
676
.await
677
-
.ok_or_else(AgentError::no_session)?;
678
679
#[cfg(feature = "tracing")]
680
let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered();
···
692
#[cfg(feature = "tracing")]
693
_span.exit();
694
695
-
let response = self.send(request).await?;
696
response.into_output().map_err(|e| match e {
697
XrpcError::Auth(auth) => AgentError::from(auth),
698
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
699
XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed),
700
})
701
}
702
}
···
790
let http_response = self
791
.send_http(http_request)
792
.await
793
-
.map_err(|e| ClientError::transport(e))?;
794
795
xrpc::process_response(http_response)
796
-
}?;
797
Ok(response.transmute())
798
}
799
}
···
837
&self.opts().await,
838
)?;
839
840
-
let http_response = self
841
-
.send_http(http_request)
842
-
.await
843
-
.map_err(|e| ClientError::transport(e))?;
844
845
xrpc::process_response(http_response)
846
-
}?;
847
let output = response.into_output().map_err(|e| match e {
848
XrpcError::Auth(auth) => AgentError::from(auth),
849
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
850
-
XrpcError::Xrpc(typed) => AgentError::new(
851
-
AgentErrorKind::SubOperation {
852
-
step: "fetch record",
853
-
},
854
-
None,
855
-
)
856
-
.with_details(typed.to_string()),
857
})?;
858
Ok(output)
859
}
···
874
{
875
let uri = uri.as_uri();
876
async move {
877
let response = self.get_record::<R>(uri).await?;
878
let response: Response<R::Record> = response.transmute();
879
let output = response.into_output().map_err(|e| match e {
880
XrpcError::Auth(auth) => AgentError::from(auth),
881
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
882
-
XrpcError::Xrpc(typed) => {
883
-
AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None)
884
-
.with_details(typed.to_string())
885
-
}
886
})?;
887
Ok(output)
888
}
···
937
// Parse to get R<'_> borrowing from response buffer
938
let record = response.parse().map_err(|e| match e {
939
XrpcError::Auth(auth) => AgentError::from(auth),
940
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
941
XrpcError::Xrpc(typed) => {
942
-
AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None)
943
-
.with_details(typed.to_string())
944
}
945
})?;
946
947
// Convert to owned
···
954
let rkey = uri
955
.rkey()
956
.ok_or_else(|| {
957
AgentError::sub_operation(
958
"extract rkey",
959
-
std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"),
960
)
961
})?
962
.clone()
···
983
let (did, _) = self
984
.session_info()
985
.await
986
-
.ok_or_else(AgentError::no_session)?;
987
#[cfg(feature = "tracing")]
988
let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered();
989
···
999
#[cfg(feature = "tracing")]
1000
_span.exit();
1001
1002
-
let response = self.send(request).await?;
1003
response.into_output().map_err(|e| match e {
1004
XrpcError::Auth(auth) => AgentError::from(auth),
1005
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
1006
XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed),
1007
})
1008
}
1009
}
···
1031
let (did, _) = self
1032
.session_info()
1033
.await
1034
-
.ok_or_else(AgentError::no_session)?;
1035
1036
let data =
1037
to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?;
···
1046
#[cfg(feature = "tracing")]
1047
_span.exit();
1048
1049
-
let response = self.send(request).await?;
1050
response.into_output().map_err(|e| match e {
1051
XrpcError::Auth(auth) => AgentError::from(auth),
1052
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
1053
XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed),
1054
})
1055
}
1056
}
···
1105
let response = self.send_with_opts(request, opts).await?;
1106
let output = response.into_output().map_err(|e| match e {
1107
XrpcError::Auth(auth) => AgentError::from(auth),
1108
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
1109
XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed),
1110
})?;
1111
Ok(output.blob.blob().clone().into_static())
1112
}
···
1148
let response = self.send(get_request).await?;
1149
let output = response.parse().map_err(|e| match e {
1150
XrpcError::Auth(auth) => AgentError::from(auth),
1151
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
1152
XrpcError::Xrpc(typed) => {
1153
AgentError::sub_operation("update vec", typed.into_static())
1154
}
1155
})?;
1156
1157
// Extract vec (converts to owned via IntoStatic)
···
674
let (did, _) = self
675
.session_info()
676
.await
677
+
.ok_or_else(|| AgentError::no_session().for_collection("create record", R::NSID))?;
678
679
#[cfg(feature = "tracing")]
680
let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered();
···
692
#[cfg(feature = "tracing")]
693
_span.exit();
694
695
+
let response = self
696
+
.send(request)
697
+
.await
698
+
.map_err(|e| e.for_collection("create record", R::NSID))?;
699
response.into_output().map_err(|e| match e {
700
XrpcError::Auth(auth) => AgentError::from(auth),
701
XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed),
702
+
e => AgentError::xrpc(e),
703
})
704
}
705
}
···
793
let http_response = self
794
.send_http(http_request)
795
.await
796
+
.map_err(|e| ClientError::transport(e).for_collection("get record", R::NSID))?;
797
798
xrpc::process_response(http_response)
799
+
.map_err(|e| e.for_collection("get record", R::NSID))?
800
+
};
801
Ok(response.transmute())
802
}
803
}
···
841
&self.opts().await,
842
)?;
843
844
+
let http_response = self.send_http(http_request).await.map_err(|e| {
845
+
ClientError::transport(e).for_collection("fetch record", collection.as_str())
846
+
})?;
847
848
xrpc::process_response(http_response)
849
+
.map_err(|e| e.for_collection("fetch record", collection.as_str()))?
850
+
};
851
let output = response.into_output().map_err(|e| match e {
852
XrpcError::Auth(auth) => AgentError::from(auth),
853
+
XrpcError::Xrpc(typed) => AgentError::sub_operation("parse record", typed),
854
+
e => AgentError::xrpc(e),
855
})?;
856
Ok(output)
857
}
···
872
{
873
let uri = uri.as_uri();
874
async move {
875
+
use smol_str::format_smolstr;
876
+
877
let response = self.get_record::<R>(uri).await?;
878
let response: Response<R::Record> = response.transmute();
879
let output = response.into_output().map_err(|e| match e {
880
XrpcError::Auth(auth) => AgentError::from(auth),
881
+
XrpcError::Xrpc(typed) => AgentError::new(
882
+
AgentErrorKind::SubOperation {
883
+
step: "parse record",
884
+
},
885
+
None,
886
+
)
887
+
.with_details(format_smolstr!("{:?}", typed)),
888
+
// Note for future orual: the above was done this way due to GAT lifetime inference constraints..
889
+
e => AgentError::xrpc(e),
890
})?;
891
Ok(output)
892
}
···
941
// Parse to get R<'_> borrowing from response buffer
942
let record = response.parse().map_err(|e| match e {
943
XrpcError::Auth(auth) => AgentError::from(auth),
944
XrpcError::Xrpc(typed) => {
945
+
AgentError::sub_operation("parse record", typed.into_static())
946
}
947
+
e => AgentError::xrpc(e),
948
})?;
949
950
// Convert to owned
···
957
let rkey = uri
958
.rkey()
959
.ok_or_else(|| {
960
+
use jacquard_common::types::string::AtStrError;
961
AgentError::sub_operation(
962
"extract rkey",
963
+
AtStrError::missing("at-uri-scheme", &uri, "rkey"),
964
)
965
})?
966
.clone()
···
987
let (did, _) = self
988
.session_info()
989
.await
990
+
.ok_or_else(|| AgentError::no_session().for_collection("delete record", R::NSID))?;
991
#[cfg(feature = "tracing")]
992
let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered();
993
···
1003
#[cfg(feature = "tracing")]
1004
_span.exit();
1005
1006
+
let response = self
1007
+
.send(request)
1008
+
.await
1009
+
.map_err(|e| e.for_collection("delete record", R::NSID))?;
1010
response.into_output().map_err(|e| match e {
1011
XrpcError::Auth(auth) => AgentError::from(auth),
1012
XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed),
1013
+
e => AgentError::xrpc(e),
1014
})
1015
}
1016
}
···
1038
let (did, _) = self
1039
.session_info()
1040
.await
1041
+
.ok_or_else(|| AgentError::no_session().for_collection("put record", R::NSID))?;
1042
1043
let data =
1044
to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?;
···
1053
#[cfg(feature = "tracing")]
1054
_span.exit();
1055
1056
+
let response = self
1057
+
.send(request)
1058
+
.await
1059
+
.map_err(|e| e.for_collection("put record", R::NSID))?;
1060
response.into_output().map_err(|e| match e {
1061
XrpcError::Auth(auth) => AgentError::from(auth),
1062
XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed),
1063
+
e => AgentError::xrpc(e),
1064
})
1065
}
1066
}
···
1115
let response = self.send_with_opts(request, opts).await?;
1116
let output = response.into_output().map_err(|e| match e {
1117
XrpcError::Auth(auth) => AgentError::from(auth),
1118
XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed),
1119
+
e => AgentError::xrpc(e),
1120
})?;
1121
Ok(output.blob.blob().clone().into_static())
1122
}
···
1158
let response = self.send(get_request).await?;
1159
let output = response.parse().map_err(|e| match e {
1160
XrpcError::Auth(auth) => AgentError::from(auth),
1161
XrpcError::Xrpc(typed) => {
1162
AgentError::sub_operation("update vec", typed.into_static())
1163
}
1164
+
e => AgentError::xrpc(e),
1165
})?;
1166
1167
// Extract vec (converts to owned via IntoStatic)
+39
-1
crates/jacquard/src/client/error.rs
+39
-1
crates/jacquard/src/client/error.rs
···
50
51
/// Error categories for Agent operations
52
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
53
pub enum AgentErrorKind {
54
/// Transport/network layer failure
55
#[error("client error (see context for details)")]
···
172
self
173
}
174
175
/// Add XRPC error data to this error for observability
176
pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self
177
where
···
253
fn from(e: ClientError) -> Self {
254
use smol_str::ToSmolStr;
255
256
-
let context_msg: SmolStr;
257
let help_msg: SmolStr;
258
let url = e.url().map(|s| s.to_smolstr());
259
let details = e.details().map(|s| s.to_smolstr());
260
261
// Build context and help based on the error kind
262
match e.kind() {
···
297
help_msg = "verify storage backend is accessible".to_smolstr();
298
context_msg = "storage operation failed".to_smolstr();
299
}
300
}
301
302
let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e)));
···
50
51
/// Error categories for Agent operations
52
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
53
+
#[non_exhaustive]
54
pub enum AgentErrorKind {
55
/// Transport/network layer failure
56
#[error("client error (see context for details)")]
···
173
self
174
}
175
176
+
/// Append additional context to existing context string.
177
+
///
178
+
/// If context already exists, appends with ": " separator.
179
+
/// If no context exists, sets it directly.
180
+
pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
181
+
self.context = Some(match self.context.take() {
182
+
Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
183
+
None => additional.as_ref().into(),
184
+
});
185
+
self
186
+
}
187
+
188
+
/// Add NSID context for XRPC operations.
189
+
///
190
+
/// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`.
191
+
pub fn for_nsid(self, nsid: &str) -> Self {
192
+
self.append_context(smol_str::format_smolstr!("[{}]", nsid))
193
+
}
194
+
195
+
/// Add collection context for record operations.
196
+
///
197
+
/// Use this when a record operation fails to indicate the target collection.
198
+
pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self {
199
+
self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid))
200
+
}
201
+
202
/// Add XRPC error data to this error for observability
203
pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self
204
where
···
280
fn from(e: ClientError) -> Self {
281
use smol_str::ToSmolStr;
282
283
+
let mut context_msg: SmolStr;
284
let help_msg: SmolStr;
285
let url = e.url().map(|s| s.to_smolstr());
286
let details = e.details().map(|s| s.to_smolstr());
287
+
// Preserve original context from ClientError to append later
288
+
let original_context = e.context().map(|s| s.to_smolstr());
289
290
// Build context and help based on the error kind
291
match e.kind() {
···
326
help_msg = "verify storage backend is accessible".to_smolstr();
327
context_msg = "storage operation failed".to_smolstr();
328
}
329
+
_ => {
330
+
help_msg = "see source error for details".to_smolstr();
331
+
context_msg = "client error".to_smolstr();
332
+
}
333
+
}
334
+
335
+
// Append original context from ClientError if present
336
+
if let Some(original) = original_context {
337
+
context_msg = smol_str::format_smolstr!("{}: {}", context_msg, original);
338
}
339
340
let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e)));
+14
crates/jacquard/src/client/token.rs
+14
crates/jacquard/src/client/token.rs
···
243
244
impl FileAuthStore {
245
/// Create a new file-backed auth store wrapping `FileTokenStore`.
246
+
///
247
+
/// # Errors
248
+
///
249
+
/// Returns an error if parent directories cannot be created or the file cannot be written.
250
+
pub fn try_new(path: impl AsRef<std::path::Path>) -> Result<Self, SessionStoreError> {
251
+
Ok(Self(FileTokenStore::try_new(path)?))
252
+
}
253
+
254
+
/// Create a new file-backed auth store wrapping `FileTokenStore`.
255
+
///
256
+
/// # Panics
257
+
///
258
+
/// Panics if parent directories cannot be created or the file cannot be written.
259
+
/// Prefer [`try_new`](Self::try_new) for fallible construction.
260
pub fn new(path: impl AsRef<std::path::Path>) -> Self {
261
Self(FileTokenStore::new(path))
262
}
+2
-1
crates/jacquard/src/moderation/fetch.rs
+2
-1
crates/jacquard/src/moderation/fetch.rs
···
34
XrpcError::Generic(g) => ClientError::decode(g.to_string()),
35
XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)),
36
XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)),
37
})?;
38
39
let mut defs = LabelerDefs::new();
···
132
.into_output()
133
.map_err(|e| match e {
134
XrpcError::Auth(auth) => AgentError::from(auth),
135
-
e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
136
XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)),
137
})?;
138
Ok((labels.labels, labels.cursor))
139
}
···
34
XrpcError::Generic(g) => ClientError::decode(g.to_string()),
35
XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)),
36
XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)),
37
+
_ => ClientError::decode("unknown XRPC error"),
38
})?;
39
40
let mut defs = LabelerDefs::new();
···
133
.into_output()
134
.map_err(|e| match e {
135
XrpcError::Auth(auth) => AgentError::from(auth),
136
XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)),
137
+
e => AgentError::xrpc(e),
138
})?;
139
Ok((labels.labels, labels.cursor))
140
}
+1
crates/jacquard/src/richtext.rs
+1
crates/jacquard/src/richtext.rs
···
699
700
/// Errors that can occur during richtext building
701
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
702
pub enum RichTextError {
703
/// Handle found that needs resolution but no resolver provided
704
#[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
···
699
700
/// Errors that can occur during richtext building
701
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
702
+
#[non_exhaustive]
703
pub enum RichTextError {
704
/// Handle found that needs resolution but no resolver provided
705
#[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]