diff --git a/Cargo.lock b/Cargo.lock index 815f4f55d..c19d8dcbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2653,6 +2653,7 @@ dependencies = [ "jose-jwk", "k256", "miette", + "mini-moka-wasm", "n0-future", "p256", "p384", diff --git a/crates/jacquard-common/src/opt_serde_bytes_helper.rs b/crates/jacquard-common/src/opt_serde_bytes_helper.rs index 46498c051..1705c3ec0 100644 --- a/crates/jacquard-common/src/opt_serde_bytes_helper.rs +++ b/crates/jacquard-common/src/opt_serde_bytes_helper.rs @@ -53,7 +53,28 @@ impl<'de> Visitor<'de> for OptBytesVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a base64-encoded string") + formatter.write_str("a base64-encoded $bytes object or structured bytes") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: de::Error, + { + Ok(Some(Bytes::copy_from_slice(v))) + } + + fn visit_borrowed_bytes(self, v: &'de [u8]) -> Result + where + E: de::Error, + { + Ok(Some(Bytes::copy_from_slice(v))) + } + + fn visit_byte_buf(self, v: Vec) -> Result + where + E: de::Error, + { + Ok(Some(Bytes::from_owner(v))) } fn visit_none(self) -> Result diff --git a/crates/jacquard-common/src/serde_bytes_helper.rs b/crates/jacquard-common/src/serde_bytes_helper.rs index 95ceef06c..de6a425a3 100644 --- a/crates/jacquard-common/src/serde_bytes_helper.rs +++ b/crates/jacquard-common/src/serde_bytes_helper.rs @@ -50,7 +50,28 @@ impl<'de> Visitor<'de> for BytesVisitor { type Value = Bytes; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a base64-encoded string") + formatter.write_str("a base64-encoded $bytes object or structured bytes") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: de::Error, + { + Ok(Bytes::copy_from_slice(v)) + } + + fn visit_borrowed_bytes(self, v: &'de [u8]) -> Result + where + E: de::Error, + { + Ok(Bytes::copy_from_slice(v)) + } + + fn visit_byte_buf(self, v: Vec) -> Result + where + E: de::Error, + { + Ok(Bytes::from_owner(v)) } fn visit_map(self, mut map: A) -> Result diff --git a/crates/jacquard-common/src/types/cid.rs b/crates/jacquard-common/src/types/cid.rs index b3c65bb9f..50b47c80f 100644 --- a/crates/jacquard-common/src/types/cid.rs +++ b/crates/jacquard-common/src/types/cid.rs @@ -168,9 +168,12 @@ impl + AsRef> Serialize for Cid { where Ser: Serializer, { - match self { - Cid::Ipld { cid, s: _ } => cid.serialize(serializer), - Cid::Str(s) => s.as_ref().serialize(serializer), + if serializer.is_human_readable() { + self.as_str().serialize(serializer) + } else { + self.to_ipld() + .map_err(serde::ser::Error::custom)? + .serialize(serializer) } } } @@ -192,7 +195,7 @@ where let s = S::deserialize(deserializer)?; Ok(Cid::Str(s)) } else { - // CBOR: use IpldCid's deserializer which handles CBOR tag 42. + // CBOR/postcard: use IpldCid's deserializer for canonical CID bytes. let cid = IpldCid::deserialize(deserializer)?; Ok(Cid::ipld(cid)) } @@ -655,4 +658,24 @@ mod tests { assert_eq!(&*link, TEST_CID); assert_eq!(link.as_ref(), TEST_CID); } + + #[test] + fn cid_string_roundtrips_through_postcard_as_binary_cid() { + let cid = Cid::::from(TEST_CID.to_string()); + let bytes = postcard::to_allocvec(&cid).unwrap(); + let parsed: Cid = postcard::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.as_str(), TEST_CID); + assert!(matches!(parsed, Cid::Ipld { .. })); + } + + #[test] + fn cid_string_roundtrips_through_dag_cbor_as_binary_cid() { + let cid = Cid::::from(TEST_CID.to_string()); + let bytes = serde_ipld_dagcbor::to_vec(&cid).unwrap(); + let parsed: Cid = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + + assert_eq!(parsed.as_str(), TEST_CID); + assert!(matches!(parsed, Cid::Ipld { .. })); + } } diff --git a/crates/jacquard-common/src/types/value/tests.rs b/crates/jacquard-common/src/types/value/tests.rs index d069af1e4..a20f8798f 100644 --- a/crates/jacquard-common/src/types/value/tests.rs +++ b/crates/jacquard-common/src/types/value/tests.rs @@ -78,6 +78,25 @@ fn serialize_deserialize_bytes_json() { assert_eq!(data, parsed); } +#[test] +fn from_data_deserializes_structured_bytes() { + #[derive(serde::Deserialize)] + struct HasBytes { + #[serde(with = "crate::serde_bytes_helper")] + bytes: Bytes, + } + + let mut map = BTreeMap::new(); + map.insert( + "bytes".to_smolstr(), + Data::Bytes(Bytes::from_static(b"hello")), + ); + let data: Data = Data::Object(Object(map)); + + let parsed: HasBytes = from_data(&data).unwrap(); + assert_eq!(parsed.bytes.as_ref(), b"hello"); +} + #[test] fn serialize_deserialize_cid_link_json() { let data = Data::CidLink(Cid::cow_str(CowStr::Borrowed( diff --git a/crates/jacquard-lexicon/src/codegen.rs b/crates/jacquard-lexicon/src/codegen.rs index 2dc232607..ddc94e617 100644 --- a/crates/jacquard-lexicon/src/codegen.rs +++ b/crates/jacquard-lexicon/src/codegen.rs @@ -156,7 +156,8 @@ impl<'c> CodeGenerator<'c> { }; // Extract validation checks for this specific def. - let validation_checks = schema_impl::extract_validation_checks(lex_doc, def_name); + let validation_checks = + schema_impl::extract_validation_checks(self.corpus, lex_doc, def_name); let validation_code = crate::derive_impl::doc_to_tokens::validations_to_tokens_resolved( &validation_checks, Some(resolved), diff --git a/crates/jacquard-lexicon/src/codegen/schema_impl.rs b/crates/jacquard-lexicon/src/codegen/schema_impl.rs index a758e33e3..dc32bdf2b 100644 --- a/crates/jacquard-lexicon/src/codegen/schema_impl.rs +++ b/crates/jacquard-lexicon/src/codegen/schema_impl.rs @@ -1,16 +1,23 @@ //! Generate LexiconSchema trait implementations for generated types +use crate::corpus::LexiconCorpus; use crate::lexicon::{ LexInteger, LexObject, LexObjectProperty, LexRecordRecord, LexString, LexStringFormat, LexUserType, LexiconDoc, }; +use crate::ref_utils::RefPath; use crate::schema::from_ast::{ConstraintCheck, ValidationCheck}; +use std::collections::BTreeSet; /// Extract validation checks from a LexiconDoc /// /// Walks the lexicon structure and builds ValidationCheck structs for all /// constraint fields (max_length, max_graphemes, minimum, maximum, etc.) -pub(crate) fn extract_validation_checks(doc: &LexiconDoc, def_name: &str) -> Vec { +pub(crate) fn extract_validation_checks( + corpus: &LexiconCorpus, + doc: &LexiconDoc, + def_name: &str, +) -> Vec { let mut checks = Vec::new(); // Get the specified def @@ -18,11 +25,21 @@ pub(crate) fn extract_validation_checks(doc: &LexiconDoc, def_name: &str) -> Vec match def { LexUserType::Record(rec) => match &rec.record { LexRecordRecord::Object(obj) => { - checks.extend(extract_object_validations(obj)); + checks.extend(extract_object_validations( + obj, + corpus, + doc.id.as_ref(), + &mut BTreeSet::new(), + )); } }, LexUserType::Object(obj) => { - checks.extend(extract_object_validations(obj)); + checks.extend(extract_object_validations( + obj, + corpus, + doc.id.as_ref(), + &mut BTreeSet::new(), + )); } // XRPC types, tokens, etc. don't need validation _ => {} @@ -32,60 +49,57 @@ pub(crate) fn extract_validation_checks(doc: &LexiconDoc, def_name: &str) -> Vec checks } -/// Extract validation checks from an object's properties -fn extract_object_validations(obj: &LexObject) -> Vec { +/// Extract validation checks from an object's properties. +fn extract_object_validations( + obj: &LexObject, + corpus: &LexiconCorpus, + current_nsid: &str, + seen_refs: &mut BTreeSet, +) -> Vec { let mut checks = Vec::new(); for (schema_name, prop) in &obj.properties { - // Convert schema name to field name (snake_case, with r# prefix for keywords) let field_name = field_name_from_schema(schema_name); - - // Check if required let is_required = obj .required .as_ref() .map(|req| req.iter().any(|r| r == schema_name)) .unwrap_or(false); - - // Extract checks from property checks.extend(extract_property_validations( &field_name, schema_name.as_ref(), prop, is_required, + false, + corpus, + current_nsid, + seen_refs, )); } checks } -/// Extract validation checks from a single property +/// Extract validation checks from a single property, following local refs. fn extract_property_validations( field_name: &str, schema_name: &str, prop: &LexObjectProperty, is_required: bool, + is_array_item: bool, + corpus: &LexiconCorpus, + current_nsid: &str, + seen_refs: &mut BTreeSet, ) -> Vec { - let mut checks = Vec::new(); - match prop { LexObjectProperty::String(s) => { - checks.extend(extract_string_validations( - field_name, - schema_name, - s, - is_required, - )); + extract_string_validations(field_name, schema_name, s, is_required, is_array_item) } LexObjectProperty::Integer(i) => { - checks.extend(extract_integer_validations( - field_name, - schema_name, - i, - is_required, - )); + extract_integer_validations(field_name, schema_name, i, is_required, is_array_item) } LexObjectProperty::Array(arr) => { + let mut checks = Vec::new(); if let Some(max) = arr.max_length { checks.push(ValidationCheck { field_name: field_name.to_string(), @@ -93,6 +107,7 @@ fn extract_property_validations( field_type: "Vec<_>".to_string(), is_required, is_array: true, + is_array_item: false, check: ConstraintCheck::MaxLength { max }, }); } @@ -103,11 +118,23 @@ fn extract_property_validations( field_type: "Vec<_>".to_string(), is_required, is_array: true, + is_array_item: false, check: ConstraintCheck::MinLength { min }, }); } + checks.extend(extract_array_item_validations( + field_name, + schema_name, + &arr.items, + is_required, + corpus, + current_nsid, + seen_refs, + )); + checks } LexObjectProperty::Blob(b) => { + let mut checks = Vec::new(); if let Some(max) = b.max_size { checks.push(ValidationCheck { field_name: field_name.to_string(), @@ -115,6 +142,7 @@ fn extract_property_validations( field_type: "BlobRef".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::BlobMaxSize { max }, }); } @@ -126,19 +154,85 @@ fn extract_property_validations( field_type: "BlobRef".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::BlobAccept { accept: accept.iter().map(|m| m.as_str().to_string()).collect(), }, }); } } + checks } - _ => { - // Other types don't have runtime validations in the current impl. + LexObjectProperty::Ref(r) => { + let normalized_ref = RefPath::normalize(r.r#ref.as_ref(), current_nsid); + if !seen_refs.insert(normalized_ref.clone()) { + return Vec::new(); + } + let Some((ref_doc, ref_def)) = corpus.resolve_ref(&normalized_ref) else { + return Vec::new(); + }; + let checks = match ref_def { + LexUserType::String(s) => extract_string_validations( + field_name, + schema_name, + s, + is_required, + is_array_item, + ), + LexUserType::Integer(i) => extract_integer_validations( + field_name, + schema_name, + i, + is_required, + is_array_item, + ), + LexUserType::Array(a) => extract_property_validations( + field_name, + schema_name, + &LexObjectProperty::Array(a.clone()), + is_required, + is_array_item, + corpus, + ref_doc.id.as_ref(), + seen_refs, + ), + _ => Vec::new(), + }; + seen_refs.remove(&normalized_ref); + checks } + _ => Vec::new(), } +} - checks +fn extract_array_item_validations( + field_name: &str, + schema_name: &str, + item: &crate::lexicon::LexArrayItem, + is_required: bool, + corpus: &LexiconCorpus, + current_nsid: &str, + seen_refs: &mut BTreeSet, +) -> Vec { + match item { + crate::lexicon::LexArrayItem::String(s) => { + extract_string_validations(field_name, schema_name, s, is_required, true) + } + crate::lexicon::LexArrayItem::Integer(i) => { + extract_integer_validations(field_name, schema_name, i, is_required, true) + } + crate::lexicon::LexArrayItem::Ref(r) => extract_property_validations( + field_name, + schema_name, + &LexObjectProperty::Ref(r.clone()), + is_required, + true, + corpus, + current_nsid, + seen_refs, + ), + _ => Vec::new(), + } } /// Extract validation checks from a string property @@ -147,6 +241,7 @@ fn extract_string_validations( schema_name: &str, string: &LexString, is_required: bool, + is_array_item: bool, ) -> Vec { let mut checks = Vec::new(); @@ -165,6 +260,7 @@ fn extract_string_validations( field_type: "String".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::MaxLength { max }, }); } @@ -176,6 +272,7 @@ fn extract_string_validations( field_type: "String".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::MinLength { min }, }); } @@ -187,6 +284,7 @@ fn extract_string_validations( field_type: "String".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::MaxGraphemes { max }, }); } @@ -198,6 +296,7 @@ fn extract_string_validations( field_type: "String".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::MinGraphemes { min }, }); } @@ -211,6 +310,7 @@ fn extract_integer_validations( schema_name: &str, integer: &LexInteger, is_required: bool, + is_array_item: bool, ) -> Vec { let mut checks = Vec::new(); @@ -221,6 +321,7 @@ fn extract_integer_validations( field_type: "i64".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::Maximum { max }, }); } @@ -232,6 +333,7 @@ fn extract_integer_validations( field_type: "i64".to_string(), is_required, is_array: false, + is_array_item, check: ConstraintCheck::Minimum { min }, }); } @@ -247,3 +349,86 @@ fn field_name_from_schema(schema_name: &str) -> String { use heck::ToSnakeCase; schema_name.to_snake_case() } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn follows_reused_cross_namespace_scalar_refs_and_array_items() { + let directory = tempfile::tempdir().expect("create fixture directory"); + fs::write( + directory.path().join("defs.json"), + r#"{ + "lexicon": 1, + "id": "test.shared.defs", + "defs": { + "name": { + "type": "string", + "maxLength": 4, + "maxGraphemes": 4 + } + } + }"#, + ) + .expect("write shared defs"); + fs::write( + directory.path().join("record.json"), + r#"{ + "lexicon": 1, + "id": "test.consumer.record", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["name", "tags"], + "properties": { + "name": { + "type": "ref", + "ref": "test.shared.defs#name" + }, + "tags": { + "type": "array", + "items": { + "type": "ref", + "ref": "test.shared.defs#name" + } + } + } + } + } + } + }"#, + ) + .expect("write consumer record"); + + let corpus = LexiconCorpus::load_from_dir(directory.path()).expect("load fixture corpus"); + let doc = corpus + .get("test.consumer.record") + .expect("get consumer record"); + let checks = extract_validation_checks(&corpus, doc, "main"); + + assert_eq!( + checks + .iter() + .filter(|check| check.field_name == "name") + .count(), + 2 + ); + assert_eq!( + checks + .iter() + .filter(|check| check.field_name == "tags") + .count(), + 2 + ); + assert!( + checks + .iter() + .filter(|check| check.field_name == "tags") + .all(|check| check.is_array_item) + ); + } +} diff --git a/crates/jacquard-lexicon/src/derive_impl/doc_to_tokens.rs b/crates/jacquard-lexicon/src/derive_impl/doc_to_tokens.rs index e43ddcedc..f629b97dd 100644 --- a/crates/jacquard-lexicon/src/derive_impl/doc_to_tokens.rs +++ b/crates/jacquard-lexicon/src/derive_impl/doc_to_tokens.rs @@ -890,7 +890,21 @@ pub fn validations_to_tokens_resolved( }, }; - if check.is_required { + if check.is_array_item && check.is_required { + quote! { + for value in &self.#field_ident { + #inner_check + } + } + } else if check.is_array_item { + quote! { + if let Some(values) = &self.#field_ident { + for value in values { + #inner_check + } + } + } + } else if check.is_required { quote! { { let value = &self.#field_ident; diff --git a/crates/jacquard-lexicon/src/schema/from_ast/properties.rs b/crates/jacquard-lexicon/src/schema/from_ast/properties.rs index 9f7ad12d6..84129236e 100644 --- a/crates/jacquard-lexicon/src/schema/from_ast/properties.rs +++ b/crates/jacquard-lexicon/src/schema/from_ast/properties.rs @@ -554,6 +554,7 @@ fn build_validations( field_type: field_type_str.clone(), is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::MaxLength { max }, }); } @@ -564,6 +565,7 @@ fn build_validations( field_type: field_type_str.clone(), is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::MaxGraphemes { max }, }); } @@ -574,6 +576,7 @@ fn build_validations( field_type: field_type_str.clone(), is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::MinLength { min }, }); } @@ -584,6 +587,7 @@ fn build_validations( field_type: field_type_str, is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::MinGraphemes { min }, }); } @@ -596,6 +600,7 @@ fn build_validations( field_type: field_type_str.clone(), is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::Maximum { max }, }); } @@ -606,6 +611,7 @@ fn build_validations( field_type: field_type_str, is_required, is_array: false, + is_array_item: false, check: ConstraintCheck::Minimum { min }, }); } @@ -618,6 +624,7 @@ fn build_validations( field_type: field_type_str.clone(), is_required, is_array: true, + is_array_item: false, check: ConstraintCheck::MaxLength { max }, }); } @@ -628,6 +635,7 @@ fn build_validations( field_type: field_type_str, is_required, is_array: true, + is_array_item: false, check: ConstraintCheck::MinLength { min }, }); } diff --git a/crates/jacquard-lexicon/src/schema/from_ast/types.rs b/crates/jacquard-lexicon/src/schema/from_ast/types.rs index bce39c694..c59cbaee7 100644 --- a/crates/jacquard-lexicon/src/schema/from_ast/types.rs +++ b/crates/jacquard-lexicon/src/schema/from_ast/types.rs @@ -44,6 +44,8 @@ pub struct ValidationCheck { pub is_required: bool, /// Is this validating an array length (vs string length)? pub is_array: bool, + /// Is this validating each item of an array? + pub is_array_item: bool, /// The specific constraint to check pub check: ConstraintCheck, } diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml index ce63820b6..73c4ca886 100644 --- a/crates/jacquard-oauth/Cargo.toml +++ b/crates/jacquard-oauth/Cargo.toml @@ -45,6 +45,7 @@ http.workspace = true bytes.workspace = true rand = { version = "0.8.5", features = ["small_rng"] } dashmap = "6.1.0" +mini-moka = { package = "mini-moka-wasm", version = "0.10", path = "../mini-moka-wasm" } tokio = { workspace = true, default-features = false, features = ["sync"] } trait-variant.workspace = true n0-future = { workspace = true, optional = true } diff --git a/crates/jacquard-oauth/src/client.rs b/crates/jacquard-oauth/src/client.rs index 619f9696d..02f3b53be 100644 --- a/crates/jacquard-oauth/src/client.rs +++ b/crates/jacquard-oauth/src/client.rs @@ -9,6 +9,8 @@ use crate::{ session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry}, types::{AuthorizeOptions, CallbackParams}, }; +use jacquard_common::session::SessionKey; + #[cfg(feature = "scope-check")] use crate::{ error::ScopeError, @@ -144,14 +146,6 @@ where { /// Create an OAuth client from an explicit resolver instance, taking ownership of both. pub fn new_from_resolver(store: S, client: T, client_data: ClientData) -> Self { - // #[cfg(feature = "tracing")] - // tracing::info!( - // redirect_uris = ?client_data.config.redirect_uris, - // scopes = ?client_data.config.scopes, - // has_keyset = client_data.keyset.is_some(), - // "oauth client created:" - // ); - let client = Arc::new(client); let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); Self { @@ -387,13 +381,26 @@ where } /// Restore a previously created session from the backing store, refreshing tokens if needed. + /// + /// Returns a cached session if one is still live for this `(DID, session_id)`, + /// avoiding a fresh store read + token refresh + `OAuthSession` construction + /// on every call. This is critical for rapid sequential PDS operations + /// (e.g. `stageFile` followed by `uploadBlob`) which must share the same + /// DPoP nonce state. pub async fn restore( &self, did: &Did, session_id: &str, ) -> Result> { - self.create_session(self.registry.get(did, session_id, true).await?) - .await + let key = SessionKey::new(did.borrow().into_static(), session_id); + if let Some(session) = self.registry.cache().get(&key) { + return Ok(session); + } + let session = self + .create_session(self.registry.get(did, session_id, true).await?) + .await?; + self.registry.cache().insert(key, session.clone()); + Ok(session) } /// Resume a stored session for `input`, or begin OAuth authorization and return a login URL. @@ -684,9 +691,26 @@ where /// Optional WebSocket client; `()` when WebSocket support is not required. pub ws_client: W, /// Mutable session data including DPoP key, nonces, and token set. - pub data: RwLock, + /// `Arc`-wrapped so cloned sessions share mutable state (DPoP nonces, tokens). + pub data: Arc>, /// Default call options applied to every outgoing XRPC request from this session. - pub options: RwLock, + pub options: Arc>, +} + +impl Clone for OAuthSession +where + T: OAuthResolver, + S: ClientAuthStore, +{ + fn clone(&self) -> Self { + Self { + registry: self.registry.clone(), + client: self.client.clone(), + ws_client: self.ws_client.clone(), + data: self.data.clone(), + options: self.options.clone(), + } + } } impl OAuthSession @@ -707,8 +731,8 @@ where registry, client, ws_client: (), - data: RwLock::new(data), - options: RwLock::new(CallOptions::default()), + data: Arc::new(RwLock::new(data)), + options: Arc::new(RwLock::new(CallOptions::default())), } } } @@ -733,8 +757,8 @@ where registry, client, ws_client, - data: RwLock::new(data), - options: RwLock::new(CallOptions::default()), + data: Arc::new(RwLock::new(data)), + options: Arc::new(RwLock::new(CallOptions::default())), } } @@ -748,7 +772,7 @@ where client: self.client, ws_client: self.ws_client, data: self.data, - options: RwLock::new(options.into_static()), + options: Arc::new(RwLock::new(options.into_static())), } } diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs index c046aa347..d54f98b6c 100644 --- a/crates/jacquard-oauth/src/session.rs +++ b/crates/jacquard-oauth/src/session.rs @@ -19,10 +19,11 @@ use jacquard_common::{ bos::{BosStr, DefaultStr}, deps::fluent_uri::Uri, http_client::HttpClient, - session::SessionStoreError, + session::{SessionKey, SessionStoreError}, types::{did::Did, string::Datetime}, }; use jose_jwk::Key; +use mini_moka::sync::Cache as MokaCache; use serde::{Deserialize, Serialize}; use smol_str::{SmolStr, ToSmolStr, format_smolstr}; use tokio::sync::Mutex; @@ -450,6 +451,13 @@ where pub client_data: ClientData, /// Per-`(DID, session_id)` mutex that serializes concurrent refresh attempts. pending: DashMap>>, + /// In-memory cache of restored sessions, keyed by `SessionKey`. + /// Prevents each `restore` from creating a fresh `OAuthSession` with its + /// own DPoP nonce state, which causes conflicts when multiple rapid + /// requests hit the same PDS session. Lazily initialized. + #[allow(dead_code)] + pub(crate) session_cache: + std::sync::OnceLock>>, } impl SessionRegistry @@ -467,6 +475,7 @@ where client, client_data, pending: DashMap::new(), + session_cache: std::sync::OnceLock::new(), } } @@ -480,6 +489,7 @@ where client, client_data, pending: DashMap::new(), + session_cache: std::sync::OnceLock::new(), } } } @@ -491,6 +501,15 @@ where Str: BosStr + FromStr + Ord + Clone, ::Err: core::fmt::Debug, { + pub(crate) fn cache(&self) -> &MokaCache> { + self.session_cache.get_or_init(|| { + MokaCache::builder() + .time_to_live(std::time::Duration::from_secs(30)) + .max_capacity(64) + .build() + }) + } + async fn get_refreshed( &self, did: &Did,