+20
CHANGELOG.md
+20
CHANGELOG.md
···
1
1
# Changelog
2
2
3
+
## [0.5.3] - 2025-10-15
4
+
5
+
### Added
6
+
7
+
**Experimental WASM Support** (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`)
8
+
- Core crates now compile for `wasm32-unknown-unknown` target
9
+
- Traits use `trait-variant` to conditionally exclude `Send` bounds on WASM
10
+
- Platform-specific trait method implementations for methods with `Self: Sync` bounds
11
+
- DNS-based handle resolution remains gated behind `dns` feature (unavailable on WASM)
12
+
- HTTPS well-known and PDS resolution work on all platforms
13
+
14
+
### Fixed
15
+
16
+
**OAuth client** (`jacquard-oauth`)
17
+
- Fixed tokio runtime detection for non-WASM targets
18
+
- Conditional compilation for tokio-specific features
19
+
20
+
21
+
---
22
+
3
23
## [0.5.2] - 2025-10-14
4
24
5
25
### Added
+23
-31
Cargo.lock
+23
-31
Cargo.lock
···
1176
1176
"cfg-if",
1177
1177
"js-sys",
1178
1178
"libc",
1179
-
"wasi 0.11.1+wasi-snapshot-preview1",
1179
+
"wasi",
1180
1180
"wasm-bindgen",
1181
1181
]
1182
1182
1183
1183
[[package]]
1184
1184
name = "getrandom"
1185
-
version = "0.3.3"
1185
+
version = "0.3.4"
1186
1186
source = "registry+https://github.com/rust-lang/crates.io-index"
1187
-
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
1187
+
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1188
1188
dependencies = [
1189
1189
"cfg-if",
1190
1190
"js-sys",
1191
1191
"libc",
1192
1192
"r-efi",
1193
-
"wasi 0.14.7+wasi-0.2.4",
1193
+
"wasip2",
1194
1194
"wasm-bindgen",
1195
1195
]
1196
1196
···
1470
1470
"libc",
1471
1471
"percent-encoding",
1472
1472
"pin-project-lite",
1473
-
"socket2 0.5.10",
1473
+
"socket2 0.6.0",
1474
1474
"system-configuration",
1475
1475
"tokio",
1476
1476
"tower-service",
···
1732
1732
1733
1733
[[package]]
1734
1734
name = "jacquard"
1735
-
version = "0.5.2"
1735
+
version = "0.5.3"
1736
1736
dependencies = [
1737
-
"async-trait",
1738
1737
"bon",
1739
1738
"bytes",
1740
1739
"clap",
1740
+
"getrandom 0.2.16",
1741
1741
"http",
1742
1742
"jacquard-api 0.5.2",
1743
1743
"jacquard-common 0.5.2",
1744
-
"jacquard-derive 0.5.2",
1744
+
"jacquard-derive 0.5.3",
1745
1745
"jacquard-identity 0.5.2",
1746
1746
"jacquard-oauth",
1747
1747
"jose-jwk",
···
1758
1758
"thiserror 2.0.17",
1759
1759
"tokio",
1760
1760
"tracing",
1761
+
"trait-variant",
1761
1762
"url",
1762
1763
]
1763
1764
···
1782
1783
"bon",
1783
1784
"bytes",
1784
1785
"jacquard-common 0.5.2",
1785
-
"jacquard-derive 0.5.2",
1786
+
"jacquard-derive 0.5.3",
1786
1787
"miette",
1787
1788
"serde",
1788
1789
"thiserror 2.0.17",
···
1800
1801
"chrono",
1801
1802
"jacquard",
1802
1803
"jacquard-common 0.5.2",
1803
-
"jacquard-derive 0.5.2",
1804
+
"jacquard-derive 0.5.3",
1804
1805
"jacquard-identity 0.5.2",
1805
1806
"k256",
1806
1807
"miette",
···
1856
1857
name = "jacquard-common"
1857
1858
version = "0.5.2"
1858
1859
dependencies = [
1859
-
"async-trait",
1860
1860
"base64 0.22.1",
1861
1861
"bon",
1862
1862
"bytes",
1863
1863
"chrono",
1864
1864
"cid",
1865
1865
"ed25519-dalek",
1866
+
"getrandom 0.3.4",
1866
1867
"http",
1867
1868
"ipld-core",
1868
1869
"k256",
···
1884
1885
"thiserror 2.0.17",
1885
1886
"tokio",
1886
1887
"tracing",
1888
+
"trait-variant",
1887
1889
"url",
1888
1890
]
1889
1891
···
1906
1908
1907
1909
[[package]]
1908
1910
name = "jacquard-derive"
1909
-
version = "0.5.2"
1911
+
version = "0.5.3"
1910
1912
dependencies = [
1911
1913
"jacquard-common 0.5.2",
1912
1914
"proc-macro2",
···
1943
1945
name = "jacquard-identity"
1944
1946
version = "0.5.2"
1945
1947
dependencies = [
1946
-
"async-trait",
1947
1948
"bon",
1948
1949
"bytes",
1949
1950
"hickory-resolver",
···
1959
1960
"thiserror 2.0.17",
1960
1961
"tokio",
1961
1962
"tracing",
1963
+
"trait-variant",
1962
1964
"url",
1963
1965
"urlencoding",
1964
1966
]
1965
1967
1966
1968
[[package]]
1967
1969
name = "jacquard-lexicon"
1968
-
version = "0.5.2"
1970
+
version = "0.5.3"
1969
1971
dependencies = [
1970
1972
"async-trait",
1971
1973
"clap",
···
1995
1997
name = "jacquard-oauth"
1996
1998
version = "0.5.2"
1997
1999
dependencies = [
1998
-
"async-trait",
1999
2000
"base64 0.22.1",
2000
2001
"bytes",
2001
2002
"chrono",
···
2308
2309
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
2309
2310
dependencies = [
2310
2311
"libc",
2311
-
"wasi 0.11.1+wasi-snapshot-preview1",
2312
+
"wasi",
2312
2313
"windows-sys 0.59.0",
2313
2314
]
2314
2315
···
2764
2765
"quinn-udp",
2765
2766
"rustc-hash",
2766
2767
"rustls",
2767
-
"socket2 0.5.10",
2768
+
"socket2 0.6.0",
2768
2769
"thiserror 2.0.17",
2769
2770
"tokio",
2770
2771
"tracing",
···
2778
2779
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
2779
2780
dependencies = [
2780
2781
"bytes",
2781
-
"getrandom 0.3.3",
2782
+
"getrandom 0.3.4",
2782
2783
"lru-slab",
2783
2784
"rand 0.9.2",
2784
2785
"ring",
···
2801
2802
"cfg_aliases",
2802
2803
"libc",
2803
2804
"once_cell",
2804
-
"socket2 0.5.10",
2805
+
"socket2 0.6.0",
2805
2806
"tracing",
2806
2807
"windows-sys 0.60.2",
2807
2808
]
···
2877
2878
source = "registry+https://github.com/rust-lang/crates.io-index"
2878
2879
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
2879
2880
dependencies = [
2880
-
"getrandom 0.3.3",
2881
+
"getrandom 0.3.4",
2881
2882
]
2882
2883
2883
2884
[[package]]
···
3624
3625
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
3625
3626
dependencies = [
3626
3627
"fastrand",
3627
-
"getrandom 0.3.3",
3628
+
"getrandom 0.3.4",
3628
3629
"once_cell",
3629
3630
"rustix",
3630
3631
"windows-sys 0.60.2",
···
4124
4125
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4125
4126
4126
4127
[[package]]
4127
-
name = "wasi"
4128
-
version = "0.14.7+wasi-0.2.4"
4129
-
source = "registry+https://github.com/rust-lang/crates.io-index"
4130
-
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
4131
-
dependencies = [
4132
-
"wasip2",
4133
-
]
4134
-
4135
-
[[package]]
4136
4128
name = "wasip2"
4137
4129
version = "1.0.1+wasi-0.2.4"
4138
4130
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4271
4263
source = "registry+https://github.com/rust-lang/crates.io-index"
4272
4264
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4273
4265
dependencies = [
4274
-
"windows-sys 0.48.0",
4266
+
"windows-sys 0.60.2",
4275
4267
]
4276
4268
4277
4269
[[package]]
+2
-3
Cargo.toml
+2
-3
Cargo.toml
···
5
5
6
6
[workspace.package]
7
7
edition = "2024"
8
-
version = "0.5.2"
8
+
version = "0.5.3"
9
9
authors = ["Orual <orual@nonbinary.computer>"]
10
10
repository = "https://tangled.org/@nonbinary.computer/jacquard"
11
11
keywords = ["atproto", "at", "bluesky", "api", "client"]
···
59
59
reqwest = { version = "0.12", default-features = false }
60
60
61
61
# Async and runtimes
62
-
async-trait = "0.1"
63
-
tokio = "1"
62
+
tokio = { version = "1", default-features = false }
64
63
65
64
# Observability
66
65
tracing = "0.1"
+13
-3
README.md
+13
-3
README.md
···
104
104
105
105
Highlights:
106
106
107
+
- experimental WASM support
107
108
- better value type deserialization helpers
108
109
- service auth implementation
109
110
- XrpcRequest derive Macros
110
111
- more builders in generated api to make constructing things easier (lmk if compile time is awful)
111
112
- `AgentSessionExt` trait with a host of convenience methods for working with records and preferences
112
113
- Improvements to the `Collection` trait, code generation, and addition of the `VecUpdate` trait to enable that
113
-
- A bunch of examples, both in the docs and in the repository
114
-
- More lexicons in the generated API bindings.
115
114
116
115
## Development
117
116
118
-
This repo uses [Flakes](https://nixos.asia/en/flakes) from the get-go.
117
+
This repo uses [Flakes](https://nixos.asia/en/flakes)
119
118
120
119
```bash
121
120
# Dev shell
···
129
128
```
130
129
131
130
There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed.
131
+
132
+
133
+
## Experimental WASM Support
134
+
135
+
Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) compile for `wasm32-unknown-unknown`. Traits use [`trait-variant`](https://docs.rs/trait-variant) to conditionally exclude `Send` bounds on WASM targets. DNS-based handle resolution is gated behind the `dns` feature and unavailable on WASM (HTTPS well-known and PDS resolution still work).
136
+
137
+
Test WASM compilation:
138
+
```bash
139
+
just check-wasm
140
+
# or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features
141
+
```
132
142
133
143
[](./LICENSE)
+10
-3
crates/jacquard-common/Cargo.toml
+10
-3
crates/jacquard-common/Cargo.toml
···
13
13
14
14
15
15
[dependencies]
16
+
trait-variant.workspace = true
16
17
bon.workspace = true
17
18
base64.workspace = true
18
19
bytes.workspace = true
···
33
34
thiserror.workspace = true
34
35
url.workspace = true
35
36
http.workspace = true
36
-
async-trait.workspace = true
37
-
tokio = { workspace = true, features = ["sync"] }
38
-
reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] }
37
+
38
+
reqwest = { workspace = true, optional = true, features = ["charset", "gzip"] }
39
39
serde_ipld_dagcbor.workspace = true
40
40
signature = { version = "2", optional = true }
41
41
tracing = { workspace = true, optional = true }
42
+
tokio = { workspace = true, default-features = false, features = ["sync"] }
43
+
44
+
[target.'cfg(target_family = "wasm")'.dependencies]
45
+
getrandom = { version = "0.3.4", features = ["wasm_js"] }
46
+
47
+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
48
+
reqwest = { workspace = true, optional = true, features = ["http2", "system-proxy", "rustls-tls"] }
42
49
43
50
[features]
44
51
default = ["service-auth", "reqwest-client", "crypto"]
+11
crates/jacquard-common/src/error.rs
+11
crates/jacquard-common/src/error.rs
···
119
119
120
120
#[cfg(feature = "reqwest-client")]
121
121
impl From<reqwest::Error> for TransportError {
122
+
#[cfg(not(target_arch = "wasm32"))]
122
123
fn from(e: reqwest::Error) -> Self {
123
124
if e.is_timeout() {
124
125
Self::Timeout
125
126
} else if e.is_connect() {
126
127
Self::Connect(e.to_string())
128
+
} else if e.is_builder() || e.is_request() {
129
+
Self::InvalidRequest(e.to_string())
130
+
} else {
131
+
Self::Other(Box::new(e))
132
+
}
133
+
}
134
+
#[cfg(target_arch = "wasm32")]
135
+
fn from(e: reqwest::Error) -> Self {
136
+
if e.is_timeout() {
137
+
Self::Timeout
127
138
} else if e.is_builder() || e.is_request() {
128
139
Self::InvalidRequest(e.to_string())
129
140
} else {
+17
-2
crates/jacquard-common/src/http_client.rs
+17
-2
crates/jacquard-common/src/http_client.rs
···
5
5
use std::sync::Arc;
6
6
7
7
/// HTTP client trait for sending raw HTTP requests.
8
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
8
9
pub trait HttpClient {
9
10
/// Error type returned by the HTTP client
10
11
type Error: std::error::Error + Display + Send + Sync + 'static;
12
+
11
13
/// Send an HTTP request and return the response.
12
14
fn send_http(
13
15
&self,
14
16
request: http::Request<Vec<u8>>,
15
-
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
17
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>;
16
18
}
17
19
18
20
#[cfg(feature = "reqwest-client")]
···
51
53
}
52
54
}
53
55
54
-
impl<T: HttpClient> HttpClient for Arc<T> {
56
+
#[cfg(not(target_arch = "wasm32"))]
57
+
impl<T: HttpClient + Sync> HttpClient for Arc<T> {
55
58
type Error = T::Error;
56
59
57
60
fn send_http(
···
62
65
self.as_ref().send_http(request)
63
66
}
64
67
}
68
+
69
+
#[cfg(target_arch = "wasm32")]
70
+
impl<T: HttpClient> HttpClient for Arc<T> {
71
+
type Error = T::Error;
72
+
73
+
fn send_http(
74
+
&self,
75
+
request: http::Request<Vec<u8>>,
76
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> {
77
+
self.as_ref().send_http(request)
78
+
}
79
+
}
+5
-7
crates/jacquard-common/src/session.rs
+5
-7
crates/jacquard-common/src/session.rs
···
1
1
//! Generic session storage traits and utilities.
2
2
3
-
use async_trait::async_trait;
4
3
use miette::Diagnostic;
5
4
use serde::Serialize;
6
5
use serde::de::DeserializeOwned;
···
8
7
use std::collections::HashMap;
9
8
use std::error::Error as StdError;
10
9
use std::fmt::Display;
10
+
use std::future::Future;
11
11
use std::hash::Hash;
12
12
use std::path::{Path, PathBuf};
13
13
use std::sync::Arc;
···
31
31
}
32
32
33
33
/// Pluggable storage for arbitrary session records.
34
-
#[async_trait]
34
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
35
35
pub trait SessionStore<K, T>: Send + Sync
36
36
where
37
37
K: Eq + Hash,
38
38
T: Clone,
39
39
{
40
40
/// Get the current session if present.
41
-
async fn get(&self, key: &K) -> Option<T>;
41
+
fn get(&self, key: &K) -> impl Future<Output = Option<T>>;
42
42
/// Persist the given session.
43
-
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>;
43
+
fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>;
44
44
/// Delete the given session.
45
-
async fn del(&self, key: &K) -> Result<(), SessionStoreError>;
45
+
fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>;
46
46
}
47
47
48
48
/// In-memory session store suitable for short-lived sessions and tests.
···
55
55
}
56
56
}
57
57
58
-
#[async_trait]
59
58
impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
60
59
where
61
60
K: Eq + Hash + Send + Sync,
···
103
102
}
104
103
}
105
104
106
-
#[async_trait::async_trait]
107
105
impl<
108
106
K: Eq + Hash + Display + Send + Sync + 'static,
109
107
T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
+6
-6
crates/jacquard-common/src/types/tid.rs
+6
-6
crates/jacquard-common/src/types/tid.rs
···
119
119
}
120
120
121
121
/// Construct a TID from a timestamp (in microseconds) and clock ID
122
-
pub fn from_time(timestamp: usize, clkid: u32) -> Self {
122
+
pub fn from_time(timestamp: u64, clkid: u32) -> Self {
123
123
let str = smol_str::format_smolstr!(
124
124
"{0}{1:2>2}",
125
125
s32_encode(timestamp as u64),
···
129
129
}
130
130
131
131
/// Extract the timestamp component (microseconds since UNIX epoch)
132
-
pub fn timestamp(&self) -> usize {
132
+
pub fn timestamp(&self) -> u64 {
133
133
s32decode(self.0[0..11].to_owned())
134
134
}
135
135
···
194
194
}
195
195
196
196
/// Decode a base32-sortable string into a usize
197
-
pub fn s32decode(s: String) -> usize {
197
+
pub fn s32decode(s: String) -> u64 {
198
198
let mut i: usize = 0;
199
199
for c in s.chars() {
200
200
i = i * 32 + S32_CHAR.chars().position(|x| x == c).unwrap();
201
201
}
202
-
i
202
+
i as u64
203
203
}
204
204
205
205
impl FromStr for Tid {
···
289
289
/// Based on adenosine/adenosine/src/identifiers.rs
290
290
/// TODO: clean up and normalize stuff between this and the stuff pulled from atrium
291
291
pub struct Ticker {
292
-
last_timestamp: usize,
292
+
last_timestamp: u64,
293
293
clock_id: u32,
294
294
}
295
295
···
311
311
let now = SystemTime::now()
312
312
.duration_since(SystemTime::UNIX_EPOCH)
313
313
.expect("timestamp in micros since UNIX epoch")
314
-
.as_micros() as usize;
314
+
.as_micros() as u64;
315
315
// mask to 53 bits
316
316
let now = now & 0x001FFFFFFFFFFFFF;
317
317
if now > self.last_timestamp {
+16
-2
crates/jacquard-common/src/xrpc.rs
+16
-2
crates/jacquard-common/src/xrpc.rs
···
226
226
impl<T: HttpClient> XrpcExt for T {}
227
227
228
228
/// Stateful XRPC call trait
229
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
229
230
pub trait XrpcClient: HttpClient {
230
231
/// Get the base URI for the client.
231
232
fn base_uri(&self) -> Url;
···
234
235
fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
235
236
async { CallOptions::default() }
236
237
}
238
+
237
239
/// Send an XRPC request and parse the response
240
+
#[cfg(not(target_arch = "wasm32"))]
238
241
fn send<R>(
239
242
&self,
240
243
request: R,
241
-
) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>> + Send
244
+
) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>>
245
+
where
246
+
R: XrpcRequest + Send + Sync,
247
+
<R as XrpcRequest>::Response: Send + Sync,
248
+
Self: Sync;
249
+
250
+
/// Send an XRPC request and parse the response
251
+
#[cfg(target_arch = "wasm32")]
252
+
fn send<R>(
253
+
&self,
254
+
request: R,
255
+
) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>>
242
256
where
243
257
R: XrpcRequest + Send + Sync,
244
258
<R as XrpcRequest>::Response: Send + Sync;
···
308
322
#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, request), fields(nsid = R::NSID)))]
309
323
pub async fn send<R>(self, request: &R) -> XrpcResult<Response<<R as XrpcRequest>::Response>>
310
324
where
311
-
R: XrpcRequest + Send + Sync,
325
+
R: XrpcRequest,
312
326
<R as XrpcRequest>::Response: Send + Sync,
313
327
{
314
328
let http_request = build_http_request(&self.base, request, &self.opts)
+6
-3
crates/jacquard-identity/Cargo.toml
+6
-3
crates/jacquard-identity/Cargo.toml
···
17
17
tracing = ["dep:tracing"]
18
18
19
19
[dependencies]
20
-
async-trait.workspace = true
20
+
trait-variant.workspace = true
21
21
bon.workspace = true
22
22
bytes.workspace = true
23
23
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
···
25
25
percent-encoding.workspace = true
26
26
reqwest.workspace = true
27
27
url.workspace = true
28
-
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
29
-
hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]}
30
28
serde.workspace = true
31
29
serde_json.workspace = true
32
30
thiserror.workspace = true
···
35
33
serde_html_form.workspace = true
36
34
urlencoding.workspace = true
37
35
tracing = { workspace = true, optional = true }
36
+
37
+
38
+
[target.'cfg(not(target_family = "wasm"))'.dependencies]
39
+
hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]}
40
+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+4
-3
crates/jacquard-identity/src/lib.rs
+4
-3
crates/jacquard-identity/src/lib.rs
···
66
66
//! Both support `.parse()` for borrowing and validation.
67
67
68
68
// use crate::CowStr; // not currently needed directly here
69
+
70
+
#![cfg_attr(target_arch = "wasm32", allow(unused))]
69
71
pub mod resolver;
70
72
71
73
use crate::resolver::{
···
84
86
use jacquard_common::{IntoStatic, types::string::Handle};
85
87
use percent_encoding::percent_decode_str;
86
88
use reqwest::StatusCode;
87
-
use std::sync::Arc;
88
89
use url::{ParseError, Url};
89
90
90
-
#[cfg(feature = "dns")]
91
-
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
91
+
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
92
+
use {hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}, std::sync::Arc};
92
93
93
94
/// Default resolver implementation with configurable fallback order.
94
95
#[derive(Clone)]
+136
-18
crates/jacquard-identity/src/resolver.rs
+136
-18
crates/jacquard-identity/src/resolver.rs
···
8
8
//!
9
9
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
10
//! and optionally validate the document `id` against the requested DID.
11
-
12
-
use std::collections::BTreeMap;
13
-
use std::marker::Sync;
14
-
use std::str::FromStr;
15
-
11
+
#![cfg_attr(target_arch = "wasm32", allow(unused))]
16
12
use bon::Builder;
17
13
use bytes::Bytes;
18
14
use http::StatusCode;
···
25
21
use jacquard_common::types::value::{AtDataError, Data};
26
22
use jacquard_common::{CowStr, IntoStatic};
27
23
use miette::Diagnostic;
24
+
use std::collections::BTreeMap;
25
+
use std::marker::Sync;
26
+
use std::str::FromStr;
28
27
use thiserror::Error;
29
28
use url::Url;
30
29
···
73
72
#[diagnostic(code(jacquard_identity::url))]
74
73
Url(#[from] url::ParseError),
75
74
#[error("DNS error: {0}")]
76
-
#[cfg(feature = "dns")]
75
+
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
77
76
#[diagnostic(code(jacquard_identity::dns))]
78
77
Dns(#[from] hickory_resolver::error::ResolveError),
79
78
#[error("serialize/deserialize error: {0}")]
···
299
298
fn default() -> Self {
300
299
// By default, prefer DNS then HTTPS for handles, then PDS fallback
301
300
// For DID documents, prefer method-native sources, then PDS fallback
301
+
let mut handle_order = vec![];
302
+
#[cfg(not(target_family = "wasm"))]
303
+
handle_order.push(HandleStep::DnsTxt);
304
+
handle_order.push(HandleStep::HttpsWellKnown);
305
+
handle_order.push(HandleStep::PdsResolveHandle);
306
+
302
307
Self::new()
303
308
.plc_source(PlcSource::default())
304
-
.handle_order(vec![
305
-
HandleStep::DnsTxt,
306
-
HandleStep::HttpsWellKnown,
307
-
HandleStep::PdsResolveHandle,
308
-
])
309
+
.handle_order(handle_order)
309
310
.did_order(vec![
310
311
DidStep::DidWebHttps,
311
312
DidStep::PlcHttp,
···
326
327
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
327
328
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
328
329
330
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
329
331
pub trait IdentityResolver {
330
332
/// Access options for validation decisions in default methods
331
333
fn options(&self) -> &ResolverOptions;
332
334
333
335
/// Resolve handle
336
+
#[cfg(not(target_arch = "wasm32"))]
334
337
fn resolve_handle(
335
338
&self,
336
339
handle: &Handle<'_>,
337
-
) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send
340
+
) -> impl Future<Output = Result<Did<'static>, IdentityError>>
338
341
where
339
342
Self: Sync;
340
343
344
+
/// Resolve handle
345
+
#[cfg(target_arch = "wasm32")]
346
+
fn resolve_handle(
347
+
&self,
348
+
handle: &Handle<'_>,
349
+
) -> impl Future<Output = Result<Did<'static>, IdentityError>>;
350
+
341
351
/// Resolve DID document
352
+
#[cfg(not(target_arch = "wasm32"))]
342
353
fn resolve_did_doc(
343
354
&self,
344
355
did: &Did<'_>,
345
-
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send
356
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>>
346
357
where
347
358
Self: Sync;
359
+
360
+
/// Resolve DID document
361
+
#[cfg(target_arch = "wasm32")]
362
+
fn resolve_did_doc(
363
+
&self,
364
+
did: &Did<'_>,
365
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>>;
348
366
349
367
/// Resolve DID doc from an identifier
368
+
#[cfg(not(target_arch = "wasm32"))]
350
369
fn resolve_ident(
351
370
&self,
352
371
actor: &AtIdentifier<'_>,
353
-
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send
372
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>>
354
373
where
355
374
Self: Sync,
356
375
{
···
366
385
}
367
386
368
387
/// Resolve DID doc from an identifier
388
+
#[cfg(target_arch = "wasm32")]
389
+
fn resolve_ident(
390
+
&self,
391
+
actor: &AtIdentifier<'_>,
392
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> {
393
+
async move {
394
+
match actor {
395
+
AtIdentifier::Did(did) => self.resolve_did_doc(&did).await,
396
+
AtIdentifier::Handle(handle) => {
397
+
let did = self.resolve_handle(&handle).await?;
398
+
self.resolve_did_doc(&did).await
399
+
}
400
+
}
401
+
}
402
+
}
403
+
404
+
/// Resolve DID doc from an identifier
405
+
#[cfg(not(target_arch = "wasm32"))]
369
406
fn resolve_ident_owned(
370
407
&self,
371
408
actor: &AtIdentifier<'_>,
372
-
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send
409
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>>
373
410
where
374
411
Self: Sync,
375
412
{
···
384
421
}
385
422
}
386
423
424
+
/// Resolve DID doc from an identifier
425
+
#[cfg(target_arch = "wasm32")]
426
+
fn resolve_ident_owned(
427
+
&self,
428
+
actor: &AtIdentifier<'_>,
429
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> {
430
+
async move {
431
+
match actor {
432
+
AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await,
433
+
AtIdentifier::Handle(handle) => {
434
+
let did = self.resolve_handle(&handle).await?;
435
+
self.resolve_did_doc_owned(&did).await
436
+
}
437
+
}
438
+
}
439
+
}
440
+
387
441
/// Resolve the DID document and return an owned version
442
+
#[cfg(not(target_arch = "wasm32"))]
388
443
fn resolve_did_doc_owned(
389
444
&self,
390
445
did: &Did<'_>,
391
-
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send
446
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>>
392
447
where
393
448
Self: Sync,
394
449
{
395
450
async { self.resolve_did_doc(did).await?.into_owned() }
396
451
}
452
+
453
+
/// Resolve the DID document and return an owned version
454
+
#[cfg(target_arch = "wasm32")]
455
+
fn resolve_did_doc_owned(
456
+
&self,
457
+
did: &Did<'_>,
458
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> {
459
+
async { self.resolve_did_doc(did).await?.into_owned() }
460
+
}
461
+
397
462
/// Return the PDS url for a DID
398
-
fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> + Send
463
+
#[cfg(not(target_arch = "wasm32"))]
464
+
fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>>
399
465
where
400
466
Self: Sync,
401
467
{
···
414
480
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
415
481
}
416
482
}
483
+
484
+
/// Return the PDS url for a DID
485
+
#[cfg(target_arch = "wasm32")]
486
+
fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> {
487
+
async {
488
+
let resp = self.resolve_did_doc(did).await?;
489
+
let doc = resp.parse()?;
490
+
// Default-on doc id equality check
491
+
if self.options().validate_doc_id {
492
+
if doc.id.as_str() != did.as_str() {
493
+
return Err(IdentityError::DocIdMismatch {
494
+
expected: did.clone().into_static(),
495
+
doc: doc.clone().into_static(),
496
+
});
497
+
}
498
+
}
499
+
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
500
+
}
501
+
}
502
+
417
503
/// Return the DIS and PDS url for a handle
504
+
#[cfg(not(target_arch = "wasm32"))]
418
505
fn pds_for_handle(
419
506
&self,
420
507
handle: &Handle<'_>,
421
-
) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> + Send
508
+
) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>>
422
509
where
423
510
Self: Sync,
424
511
{
···
428
515
Ok((did, pds))
429
516
}
430
517
}
518
+
519
+
/// Return the DIS and PDS url for a handle
520
+
#[cfg(target_arch = "wasm32")]
521
+
fn pds_for_handle(
522
+
&self,
523
+
handle: &Handle<'_>,
524
+
) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> {
525
+
async {
526
+
let did = self.resolve_handle(handle).await?;
527
+
let pds = self.pds_for_did(&did).await?;
528
+
Ok((did, pds))
529
+
}
530
+
}
431
531
}
432
532
533
+
#[cfg(not(target_arch = "wasm32"))]
433
534
impl<T: IdentityResolver + Sync> IdentityResolver for std::sync::Arc<T> {
535
+
fn options(&self) -> &ResolverOptions {
536
+
self.as_ref().options()
537
+
}
538
+
539
+
/// Resolve handle
540
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
541
+
self.as_ref().resolve_handle(handle).await
542
+
}
543
+
544
+
/// Resolve DID document
545
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
546
+
self.as_ref().resolve_did_doc(did).await
547
+
}
548
+
}
549
+
550
+
#[cfg(target_arch = "wasm32")]
551
+
impl<T: IdentityResolver> IdentityResolver for std::sync::Arc<T> {
434
552
fn options(&self) -> &ResolverOptions {
435
553
self.as_ref().options()
436
554
}
+2
-3
crates/jacquard-lexicon/src/fetch/sources.rs
+2
-3
crates/jacquard-lexicon/src/fetch/sources.rs
···
14
14
pub use slices::SlicesSource;
15
15
16
16
use crate::lexicon::LexiconDoc;
17
-
use async_trait::async_trait;
18
17
use miette::{IntoDiagnostic, Result};
19
18
use std::collections::HashMap;
19
+
use std::future::Future;
20
20
21
21
#[derive(Debug, Clone)]
22
22
pub struct Source {
···
58
58
Slices(SlicesSource),
59
59
}
60
60
61
-
#[async_trait]
62
61
pub trait LexiconSource {
63
-
fn fetch(&self) -> impl Future<Output = Result<HashMap<String, LexiconDoc<'_>>>>;
62
+
fn fetch(&self) -> impl Future<Output = Result<HashMap<String, LexiconDoc<'_>>>> + Send;
64
63
}
65
64
66
65
impl LexiconSource for SourceType {
+5
-3
crates/jacquard-oauth/Cargo.toml
+5
-3
crates/jacquard-oauth/Cargo.toml
···
33
33
http.workspace = true
34
34
bytes.workspace = true
35
35
rand = { version = "0.8.5", features = ["small_rng"] }
36
-
async-trait.workspace = true
37
36
dashmap = "6.1.0"
38
-
tokio = { workspace = true, features = ["sync", "net", "time"] }
37
+
tokio = { workspace = true, default-features = false, features = ["sync"] }
39
38
reqwest.workspace = true
40
39
trait-variant.workspace = true
41
40
webbrowser = { version = "0.8", optional = true }
42
-
rouille = { version = "3.6.2", optional = true }
43
41
tracing = { workspace = true, optional = true }
42
+
43
+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
44
+
tokio = { workspace = true, features = ["rt", "net", "time"] }
45
+
rouille = { version = "3.6.2", optional = true }
44
46
45
47
[features]
46
48
default = []
+13
-14
crates/jacquard-oauth/src/authstore.rs
+13
-14
crates/jacquard-oauth/src/authstore.rs
···
1
+
use std::future::Future;
1
2
use std::sync::Arc;
2
3
3
4
use dashmap::DashMap;
···
10
11
11
12
use crate::session::{AuthRequestData, ClientSessionData};
12
13
13
-
#[async_trait::async_trait]
14
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
14
15
pub trait ClientAuthStore {
15
-
async fn get_session(
16
+
fn get_session(
16
17
&self,
17
18
did: &Did<'_>,
18
19
session_id: &str,
19
-
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>;
20
+
) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>>;
20
21
21
-
async fn upsert_session(&self, session: ClientSessionData<'_>)
22
-
-> Result<(), SessionStoreError>;
22
+
fn upsert_session(&self, session: ClientSessionData<'_>)
23
+
-> impl Future<Output = Result<(), SessionStoreError>>;
23
24
24
-
async fn delete_session(
25
+
fn delete_session(
25
26
&self,
26
27
did: &Did<'_>,
27
28
session_id: &str,
28
-
) -> Result<(), SessionStoreError>;
29
+
) -> impl Future<Output = Result<(), SessionStoreError>>;
29
30
30
-
async fn get_auth_req_info(
31
+
fn get_auth_req_info(
31
32
&self,
32
33
state: &str,
33
-
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>;
34
+
) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>>;
34
35
35
-
async fn save_auth_req_info(
36
+
fn save_auth_req_info(
36
37
&self,
37
38
auth_req_info: &AuthRequestData<'_>,
38
-
) -> Result<(), SessionStoreError>;
39
+
) -> impl Future<Output = Result<(), SessionStoreError>>;
39
40
40
-
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>;
41
+
fn delete_auth_req_info(&self, state: &str) -> impl Future<Output = Result<(), SessionStoreError>>;
41
42
}
42
43
43
44
pub struct MemoryAuthStore {
···
54
55
}
55
56
}
56
57
57
-
#[async_trait::async_trait]
58
58
impl ClientAuthStore for MemoryAuthStore {
59
59
async fn get_session(
60
60
&self,
···
108
108
}
109
109
}
110
110
111
-
#[async_trait::async_trait]
112
111
impl<T: ClientAuthStore + Send + Sync>
113
112
SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T>
114
113
{
+6
-8
crates/jacquard-oauth/src/client.rs
+6
-8
crates/jacquard-oauth/src/client.rs
···
421
421
{
422
422
fn base_uri(&self) -> Url {
423
423
// base_uri is a synchronous trait method; we must avoid async `.read().await`.
424
-
// Use `block_in_place` under Tokio to perform a blocking RwLock read safely.
424
+
// Use `block_in_place` under Tokio runtime to perform a blocking RwLock read safely.
425
+
#[cfg(not(target_arch = "wasm32"))]
425
426
if tokio::runtime::Handle::try_current().is_ok() {
426
-
tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone())
427
-
} else {
428
-
self.data.blocking_read().host_url.clone()
427
+
return tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone());
429
428
}
429
+
430
+
self.data.blocking_read().host_url.clone()
430
431
}
431
432
432
433
async fn opts(&self) -> CallOptions<'_> {
433
434
self.options.read().await.clone()
434
435
}
435
436
436
-
async fn send<R>(
437
-
&self,
438
-
request: R,
439
-
) -> XrpcResult<Response<<R as XrpcRequest>::Response>>
437
+
async fn send<R>(&self, request: R) -> XrpcResult<Response<<R as XrpcRequest>::Response>>
440
438
where
441
439
R: XrpcRequest,
442
440
{
+4
-4
crates/jacquard-oauth/src/dpop.rs
+4
-4
crates/jacquard-oauth/src/dpop.rs
···
41
41
42
42
type Result<T> = core::result::Result<T, Error>;
43
43
44
-
#[async_trait::async_trait]
44
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
45
45
pub trait DpopClient: HttpClient {
46
-
async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
47
-
async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
48
-
async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
46
+
fn dpop_server(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>;
47
+
fn dpop_client(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>;
48
+
fn wrap_request(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>;
49
49
}
50
50
51
51
pub trait DpopExt: HttpClient {
+377
-100
crates/jacquard-oauth/src/resolver.rs
+377
-100
crates/jacquard-oauth/src/resolver.rs
···
115
115
Uri(#[from] url::ParseError),
116
116
}
117
117
118
+
#[cfg(not(target_arch = "wasm32"))]
119
+
async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>(
120
+
resolver: &T,
121
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
122
+
sub: &Did<'_>,
123
+
) -> Result<Url, ResolverError> {
124
+
let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?;
125
+
if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
126
+
return Err(ResolverError::AuthorizationServerMetadata(
127
+
"issuer mismatch".to_string(),
128
+
));
129
+
}
130
+
Ok(identity
131
+
.pds_endpoint()
132
+
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
133
+
}
134
+
135
+
#[cfg(target_arch = "wasm32")]
136
+
async fn verify_issuer_impl<T: OAuthResolver + ?Sized>(
137
+
resolver: &T,
138
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
139
+
sub: &Did<'_>,
140
+
) -> Result<Url, ResolverError> {
141
+
let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?;
142
+
if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
143
+
return Err(ResolverError::AuthorizationServerMetadata(
144
+
"issuer mismatch".to_string(),
145
+
));
146
+
}
147
+
Ok(identity
148
+
.pds_endpoint()
149
+
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
150
+
}
151
+
152
+
#[cfg(not(target_arch = "wasm32"))]
153
+
async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>(
154
+
resolver: &T,
155
+
input: &str,
156
+
) -> Result<
157
+
(
158
+
OAuthAuthorizationServerMetadata<'static>,
159
+
Option<DidDocument<'static>>,
160
+
),
161
+
ResolverError,
162
+
> {
163
+
// Allow using an entryway, or PDS url, directly as login input (e.g.
164
+
// when the user forgot their handle, or when the handle does not
165
+
// resolve to a DID)
166
+
Ok(if input.starts_with("https://") {
167
+
let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
168
+
(resolver.resolve_from_service(&url).await?, None)
169
+
} else {
170
+
let (metadata, identity) = resolver.resolve_from_identity(input).await?;
171
+
(metadata, Some(identity))
172
+
})
173
+
}
174
+
175
+
#[cfg(target_arch = "wasm32")]
176
+
async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>(
177
+
resolver: &T,
178
+
input: &str,
179
+
) -> Result<
180
+
(
181
+
OAuthAuthorizationServerMetadata<'static>,
182
+
Option<DidDocument<'static>>,
183
+
),
184
+
ResolverError,
185
+
> {
186
+
// Allow using an entryway, or PDS url, directly as login input (e.g.
187
+
// when the user forgot their handle, or when the handle does not
188
+
// resolve to a DID)
189
+
Ok(if input.starts_with("https://") {
190
+
let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
191
+
(resolver.resolve_from_service(&url).await?, None)
192
+
} else {
193
+
let (metadata, identity) = resolver.resolve_from_identity(input).await?;
194
+
(metadata, Some(identity))
195
+
})
196
+
}
197
+
198
+
#[cfg(not(target_arch = "wasm32"))]
199
+
async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>(
200
+
resolver: &T,
201
+
input: &Url,
202
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
203
+
// Assume first that input is a PDS URL (as required by ATPROTO)
204
+
if let Ok(metadata) = resolver.get_resource_server_metadata(input).await {
205
+
return Ok(metadata);
206
+
}
207
+
// Fallback to trying to fetch as an issuer (Entryway)
208
+
resolver.get_authorization_server_metadata(input).await
209
+
}
210
+
211
+
#[cfg(target_arch = "wasm32")]
212
+
async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>(
213
+
resolver: &T,
214
+
input: &Url,
215
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
216
+
// Assume first that input is a PDS URL (as required by ATPROTO)
217
+
if let Ok(metadata) = resolver.get_resource_server_metadata(input).await {
218
+
return Ok(metadata);
219
+
}
220
+
// Fallback to trying to fetch as an issuer (Entryway)
221
+
resolver.get_authorization_server_metadata(input).await
222
+
}
223
+
224
+
#[cfg(not(target_arch = "wasm32"))]
225
+
async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>(
226
+
resolver: &T,
227
+
input: &str,
228
+
) -> Result<
229
+
(
230
+
OAuthAuthorizationServerMetadata<'static>,
231
+
DidDocument<'static>,
232
+
),
233
+
ResolverError,
234
+
> {
235
+
let actor =
236
+
AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
237
+
let identity = resolver.resolve_ident_owned(&actor).await?;
238
+
if let Some(pds) = &identity.pds_endpoint() {
239
+
let metadata = resolver.get_resource_server_metadata(pds).await?;
240
+
Ok((metadata, identity))
241
+
} else {
242
+
Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
243
+
}
244
+
}
245
+
246
+
#[cfg(target_arch = "wasm32")]
247
+
async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>(
248
+
resolver: &T,
249
+
input: &str,
250
+
) -> Result<
251
+
(
252
+
OAuthAuthorizationServerMetadata<'static>,
253
+
DidDocument<'static>,
254
+
),
255
+
ResolverError,
256
+
> {
257
+
let actor =
258
+
AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
259
+
let identity = resolver.resolve_ident_owned(&actor).await?;
260
+
if let Some(pds) = &identity.pds_endpoint() {
261
+
let metadata = resolver.get_resource_server_metadata(pds).await?;
262
+
Ok((metadata, identity))
263
+
} else {
264
+
Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
265
+
}
266
+
}
267
+
268
+
#[cfg(not(target_arch = "wasm32"))]
269
+
async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>(
270
+
client: &T,
271
+
issuer: &Url,
272
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
273
+
let mut md = resolve_authorization_server(client, issuer).await?;
274
+
// Normalize issuer string to the input URL representation to avoid slash quirks
275
+
md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
276
+
Ok(md)
277
+
}
278
+
279
+
#[cfg(target_arch = "wasm32")]
280
+
async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>(
281
+
client: &T,
282
+
issuer: &Url,
283
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
284
+
let mut md = resolve_authorization_server(client, issuer).await?;
285
+
// Normalize issuer string to the input URL representation to avoid slash quirks
286
+
md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
287
+
Ok(md)
288
+
}
289
+
290
+
#[cfg(not(target_arch = "wasm32"))]
291
+
async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>(
292
+
resolver: &T,
293
+
pds: &Url,
294
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
295
+
let rs_metadata = resolve_protected_resource_info(resolver, pds).await?;
296
+
// ATPROTO requires one, and only one, authorization server entry
297
+
// > That document MUST contain a single item in the authorization_servers array.
298
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
299
+
let issuer = match &rs_metadata.authorization_servers {
300
+
Some(servers) if !servers.is_empty() => {
301
+
if servers.len() > 1 {
302
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
303
+
"unable to determine authorization server for PDS: {pds}"
304
+
)));
305
+
}
306
+
&servers[0]
307
+
}
308
+
_ => {
309
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
310
+
"no authorization server found for PDS: {pds}"
311
+
)));
312
+
}
313
+
};
314
+
let as_metadata = resolver.get_authorization_server_metadata(issuer).await?;
315
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
316
+
if let Some(protected_resources) = &as_metadata.protected_resources {
317
+
let resource_url = rs_metadata
318
+
.resource
319
+
.strip_suffix('/')
320
+
.unwrap_or(rs_metadata.resource.as_str());
321
+
if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
322
+
return Err(ResolverError::AuthorizationServerMetadata(format!(
323
+
"pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
324
+
rs_metadata.resource, protected_resources
325
+
)));
326
+
}
327
+
}
328
+
329
+
// TODO: atproot specific validation?
330
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
331
+
//
332
+
// eg.
333
+
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
334
+
// if as_metadata.client_id_metadata_document_supported != Some(true) {
335
+
// return Err(Error::AuthorizationServerMetadata(format!(
336
+
// "authorization server does not support client_id_metadata_document: {issuer}"
337
+
// )));
338
+
// }
339
+
340
+
Ok(as_metadata)
341
+
}
342
+
343
+
#[cfg(target_arch = "wasm32")]
344
+
async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>(
345
+
resolver: &T,
346
+
pds: &Url,
347
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
348
+
let rs_metadata = resolve_protected_resource_info(resolver, pds).await?;
349
+
// ATPROTO requires one, and only one, authorization server entry
350
+
// > That document MUST contain a single item in the authorization_servers array.
351
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
352
+
let issuer = match &rs_metadata.authorization_servers {
353
+
Some(servers) if !servers.is_empty() => {
354
+
if servers.len() > 1 {
355
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
356
+
"unable to determine authorization server for PDS: {pds}"
357
+
)));
358
+
}
359
+
&servers[0]
360
+
}
361
+
_ => {
362
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
363
+
"no authorization server found for PDS: {pds}"
364
+
)));
365
+
}
366
+
};
367
+
let as_metadata = resolver.get_authorization_server_metadata(issuer).await?;
368
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
369
+
if let Some(protected_resources) = &as_metadata.protected_resources {
370
+
let resource_url = rs_metadata
371
+
.resource
372
+
.strip_suffix('/')
373
+
.unwrap_or(rs_metadata.resource.as_str());
374
+
if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
375
+
return Err(ResolverError::AuthorizationServerMetadata(format!(
376
+
"pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
377
+
rs_metadata.resource, protected_resources
378
+
)));
379
+
}
380
+
}
381
+
382
+
// TODO: atproot specific validation?
383
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
384
+
//
385
+
// eg.
386
+
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
387
+
// if as_metadata.client_id_metadata_document_supported != Some(true) {
388
+
// return Err(Error::AuthorizationServerMetadata(format!(
389
+
// "authorization server does not support client_id_metadata_document: {issuer}"
390
+
// )));
391
+
// }
392
+
393
+
Ok(as_metadata)
394
+
}
395
+
396
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
118
397
pub trait OAuthResolver: IdentityResolver + HttpClient {
398
+
#[cfg(not(target_arch = "wasm32"))]
119
399
fn verify_issuer(
120
400
&self,
121
401
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
···
124
404
where
125
405
Self: Sync,
126
406
{
127
-
async {
128
-
let (metadata, identity) = self.resolve_from_identity(sub).await?;
129
-
if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
130
-
return Err(ResolverError::AuthorizationServerMetadata(
131
-
"issuer mismatch".to_string(),
132
-
));
133
-
}
134
-
Ok(identity
135
-
.pds_endpoint()
136
-
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
137
-
}
407
+
verify_issuer_impl(self, server_metadata, sub)
408
+
}
409
+
410
+
#[cfg(target_arch = "wasm32")]
411
+
fn verify_issuer(
412
+
&self,
413
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
414
+
sub: &Did<'_>,
415
+
) -> impl std::future::Future<Output = Result<Url, ResolverError>> {
416
+
verify_issuer_impl(self, server_metadata, sub)
138
417
}
418
+
419
+
#[cfg(not(target_arch = "wasm32"))]
139
420
fn resolve_oauth(
140
421
&self,
141
422
input: &str,
142
-
) -> impl Future<
423
+
) -> impl std::future::Future<
143
424
Output = Result<
144
425
(
145
426
OAuthAuthorizationServerMetadata<'static>,
···
151
432
where
152
433
Self: Sync,
153
434
{
154
-
// Allow using an entryway, or PDS url, directly as login input (e.g.
155
-
// when the user forgot their handle, or when the handle does not
156
-
// resolve to a DID)
157
-
async {
158
-
Ok(if input.starts_with("https://") {
159
-
let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
160
-
(self.resolve_from_service(&url).await?, None)
161
-
} else {
162
-
let (metadata, identity) = self.resolve_from_identity(input).await?;
163
-
(metadata, Some(identity))
164
-
})
165
-
}
435
+
resolve_oauth_impl(self, input)
436
+
}
437
+
438
+
#[cfg(target_arch = "wasm32")]
439
+
fn resolve_oauth(
440
+
&self,
441
+
input: &str,
442
+
) -> impl std::future::Future<
443
+
Output = Result<
444
+
(
445
+
OAuthAuthorizationServerMetadata<'static>,
446
+
Option<DidDocument<'static>>,
447
+
),
448
+
ResolverError,
449
+
>,
450
+
> {
451
+
resolve_oauth_impl(self, input)
166
452
}
453
+
454
+
#[cfg(not(target_arch = "wasm32"))]
167
455
fn resolve_from_service(
168
456
&self,
169
457
input: &Url,
170
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
458
+
) -> impl std::future::Future<
459
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
460
+
> + Send
171
461
where
172
462
Self: Sync,
173
463
{
174
-
async {
175
-
// Assume first that input is a PDS URL (as required by ATPROTO)
176
-
if let Ok(metadata) = self.get_resource_server_metadata(input).await {
177
-
return Ok(metadata);
178
-
}
179
-
// Fallback to trying to fetch as an issuer (Entryway)
180
-
self.get_authorization_server_metadata(input).await
181
-
}
464
+
resolve_from_service_impl(self, input)
182
465
}
466
+
467
+
#[cfg(target_arch = "wasm32")]
468
+
fn resolve_from_service(
469
+
&self,
470
+
input: &Url,
471
+
) -> impl std::future::Future<
472
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
473
+
> {
474
+
resolve_from_service_impl(self, input)
475
+
}
476
+
477
+
#[cfg(not(target_arch = "wasm32"))]
183
478
fn resolve_from_identity(
184
479
&self,
185
480
input: &str,
186
-
) -> impl Future<
481
+
) -> impl std::future::Future<
187
482
Output = Result<
188
483
(
189
484
OAuthAuthorizationServerMetadata<'static>,
···
195
490
where
196
491
Self: Sync,
197
492
{
198
-
async {
199
-
let actor = AtIdentifier::new(input)
200
-
.map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
201
-
let identity = self.resolve_ident_owned(&actor).await?;
202
-
if let Some(pds) = &identity.pds_endpoint() {
203
-
let metadata = self.get_resource_server_metadata(pds).await?;
204
-
Ok((metadata, identity))
205
-
} else {
206
-
Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
207
-
}
208
-
}
493
+
resolve_from_identity_impl(self, input)
209
494
}
495
+
496
+
#[cfg(target_arch = "wasm32")]
497
+
fn resolve_from_identity(
498
+
&self,
499
+
input: &str,
500
+
) -> impl std::future::Future<
501
+
Output = Result<
502
+
(
503
+
OAuthAuthorizationServerMetadata<'static>,
504
+
DidDocument<'static>,
505
+
),
506
+
ResolverError,
507
+
>,
508
+
> {
509
+
resolve_from_identity_impl(self, input)
510
+
}
511
+
512
+
#[cfg(not(target_arch = "wasm32"))]
210
513
fn get_authorization_server_metadata(
211
514
&self,
212
515
issuer: &Url,
213
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
516
+
) -> impl std::future::Future<
517
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
518
+
> + Send
214
519
where
215
520
Self: Sync,
216
521
{
217
-
async {
218
-
let mut md = resolve_authorization_server(self, issuer).await?;
219
-
// Normalize issuer string to the input URL representation to avoid slash quirks
220
-
md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
221
-
Ok(md)
222
-
}
522
+
get_authorization_server_metadata_impl(self, issuer)
223
523
}
524
+
525
+
#[cfg(target_arch = "wasm32")]
526
+
fn get_authorization_server_metadata(
527
+
&self,
528
+
issuer: &Url,
529
+
) -> impl std::future::Future<
530
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
531
+
> {
532
+
get_authorization_server_metadata_impl(self, issuer)
533
+
}
534
+
535
+
#[cfg(not(target_arch = "wasm32"))]
224
536
fn get_resource_server_metadata(
225
537
&self,
226
538
pds: &Url,
227
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
539
+
) -> impl std::future::Future<
540
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
541
+
> + Send
228
542
where
229
543
Self: Sync,
230
544
{
231
-
async move {
232
-
let rs_metadata = resolve_protected_resource_info(self, pds).await?;
233
-
// ATPROTO requires one, and only one, authorization server entry
234
-
// > That document MUST contain a single item in the authorization_servers array.
235
-
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
236
-
let issuer = match &rs_metadata.authorization_servers {
237
-
Some(servers) if !servers.is_empty() => {
238
-
if servers.len() > 1 {
239
-
return Err(ResolverError::ProtectedResourceMetadata(format!(
240
-
"unable to determine authorization server for PDS: {pds}"
241
-
)));
242
-
}
243
-
&servers[0]
244
-
}
245
-
_ => {
246
-
return Err(ResolverError::ProtectedResourceMetadata(format!(
247
-
"no authorization server found for PDS: {pds}"
248
-
)));
249
-
}
250
-
};
251
-
let as_metadata = self.get_authorization_server_metadata(issuer).await?;
252
-
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
253
-
if let Some(protected_resources) = &as_metadata.protected_resources {
254
-
let resource_url = rs_metadata
255
-
.resource
256
-
.strip_suffix('/')
257
-
.unwrap_or(rs_metadata.resource.as_str());
258
-
if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
259
-
return Err(ResolverError::AuthorizationServerMetadata(format!(
260
-
"pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
261
-
rs_metadata.resource, protected_resources
262
-
)));
263
-
}
264
-
}
545
+
get_resource_server_metadata_impl(self, pds)
546
+
}
265
547
266
-
// TODO: atproot specific validation?
267
-
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
268
-
//
269
-
// eg.
270
-
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
271
-
// if as_metadata.client_id_metadata_document_supported != Some(true) {
272
-
// return Err(Error::AuthorizationServerMetadata(format!(
273
-
// "authorization server does not support client_id_metadata_document: {issuer}"
274
-
// )));
275
-
// }
276
-
277
-
Ok(as_metadata)
278
-
}
548
+
#[cfg(target_arch = "wasm32")]
549
+
fn get_resource_server_metadata(
550
+
&self,
551
+
pds: &Url,
552
+
) -> impl std::future::Future<
553
+
Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>,
554
+
> {
555
+
get_resource_server_metadata_impl(self, pds)
279
556
}
280
557
}
281
558
+12
-4
crates/jacquard/Cargo.toml
+12
-4
crates/jacquard/Cargo.toml
···
22
22
api_full = ["api", "jacquard-api/bluesky", "jacquard-api/other", "jacquard-api/lexicon_community"]
23
23
# All captured generated lexicon API bindings
24
24
api_all = ["api_full", "jacquard-api/ufos"]
25
-
dns = ["jacquard-identity/dns"]
25
+
26
26
# Propagate loopback to oauth (server + browser helper)
27
27
loopback = ["jacquard-oauth/loopback", "jacquard-oauth/browser-open"]
28
28
# Enable tracing instrumentation
29
29
tracing = ["dep:tracing", "jacquard-common/tracing", "jacquard-oauth/tracing", "jacquard-identity/tracing"]
30
+
dns = ["jacquard-identity/dns"]
30
31
31
32
32
33
[[example]]
···
78
79
jacquard-identity = { version = "0.5", path = "../jacquard-identity" }
79
80
80
81
bon.workspace = true
81
-
async-trait.workspace = true
82
+
trait-variant.workspace = true
82
83
bytes.workspace = true
83
84
http.workspace = true
84
85
miette = { workspace = true }
85
-
reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] }
86
+
reqwest = { workspace = true, features = ["charset", "json", "gzip"] }
86
87
serde.workspace = true
87
88
serde_html_form.workspace = true
88
89
serde_ipld_dagcbor.workspace = true
89
90
serde_json.workspace = true
90
91
thiserror.workspace = true
91
-
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
92
+
tokio = { workspace = true, default-features = false, features = ["sync"] }
92
93
url.workspace = true
93
94
smol_str.workspace = true
94
95
percent-encoding.workspace = true
···
96
97
p256 = { workspace = true, features = ["ecdsa"] }
97
98
rand_core.workspace = true
98
99
tracing = { workspace = true, optional = true }
100
+
101
+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
102
+
reqwest = { workspace = true, features = ["http2", "system-proxy", "rustls-tls"] }
103
+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
104
+
105
+
[target.'cfg(target_family = "wasm")'.dependencies]
106
+
getrandom = { version = "0.2", features = ["js"] }
99
107
100
108
[dev-dependencies]
101
109
clap.workspace = true
+10
crates/jacquard/src/client.rs
+10
crates/jacquard/src/client.rs
···
190
190
/// Common interface for stateful sessions used by the Agent wrapper.
191
191
///
192
192
/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP).
193
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
193
194
pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
194
195
/// Identify the kind of session.
195
196
fn session_kind(&self) -> AgentKind;
···
877
878
impl<A: AgentSession> HttpClient for Agent<A> {
878
879
type Error = <A as HttpClient>::Error;
879
880
881
+
#[cfg(not(target_arch = "wasm32"))]
880
882
fn send_http(
881
883
&self,
882
884
request: http::Request<Vec<u8>>,
883
885
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
884
886
{
887
+
self.inner.send_http(request)
888
+
}
889
+
890
+
#[cfg(target_arch = "wasm32")]
891
+
fn send_http(
892
+
&self,
893
+
request: http::Request<Vec<u8>>,
894
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> {
885
895
self.inner.send_http(request)
886
896
}
887
897
}
+13
-2
crates/jacquard/src/client/credential_session.rs
+13
-2
crates/jacquard/src/client/credential_session.rs
···
168
168
S: Any + 'static,
169
169
{
170
170
#[cfg(feature = "tracing")]
171
-
let _span = tracing::info_span!("credential_session_login", identifier = %identifier).entered();
171
+
let _span =
172
+
tracing::info_span!("credential_session_login", identifier = %identifier).entered();
172
173
173
174
// Resolve PDS base
174
175
let pds = if identifier.as_ref().starts_with("http://")
···
272
273
S: Any + 'static,
273
274
{
274
275
#[cfg(feature = "tracing")]
275
-
let _span = tracing::info_span!("credential_session_restore", did = %did, session_id = %session_id).entered();
276
+
let _span =
277
+
tracing::info_span!("credential_session_restore", did = %did, session_id = %session_id)
278
+
.entered();
276
279
277
280
let key = (did.clone().into_static(), session_id.clone().into_static());
278
281
let Some(sess) = self.store.get(&key).await else {
···
401
404
fn base_uri(&self) -> Url {
402
405
// base_uri is a synchronous trait method; avoid `.await` here.
403
406
// Under Tokio, use `block_in_place` to make a blocking RwLock read safe.
407
+
#[cfg(not(target_arch = "wasm32"))]
404
408
if tokio::runtime::Handle::try_current().is_ok() {
405
409
tokio::task::block_in_place(|| {
406
410
self.endpoint.blocking_read().clone().unwrap_or(
···
409
413
)
410
414
})
411
415
} else {
416
+
self.endpoint.blocking_read().clone().unwrap_or(
417
+
Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
418
+
)
419
+
}
420
+
421
+
#[cfg(target_arch = "wasm32")]
422
+
{
412
423
self.endpoint.blocking_read().clone().unwrap_or(
413
424
Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
414
425
)
-2
crates/jacquard/src/client/token.rs
-2
crates/jacquard/src/client/token.rs
···
246
246
}
247
247
}
248
248
249
-
#[async_trait::async_trait]
250
249
impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore {
251
250
async fn get_session(
252
251
&self,
···
388
387
}
389
388
}
390
389
391
-
#[async_trait::async_trait]
392
390
impl
393
391
jacquard_common::session::SessionStore<
394
392
crate::client::credential_session::SessionKey,
+4
justfile
+4
justfile
···
5
5
pre-commit-all:
6
6
pre-commit run --all-files
7
7
8
+
# Check that jacquard-common compiles for wasm32
9
+
check-wasm:
10
+
cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features
11
+
8
12
# Run 'cargo run' on the project
9
13
run *ARGS:
10
14
cargo run {{ARGS}}