Skip to main content

meerkat_runtime/handles/
auth_lease.rs

1//! Runtime impl of [`meerkat_core::handles::AuthLeaseHandle`].
2//!
3//! Per-binding [`AuthMachine`](crate::auth_machine) instances, keyed by
4//! typed [`LeaseKey`](meerkat_core::handles::LeaseKey). Each trait
5//! method looks up or creates the corresponding machine, then fires
6//! the matching DSL input. `snapshot` projects the machine's phase
7//! + expires_at back out.
8//!
9//! The original Phase 1.5-rev design absorbed auth state into
10//! MeerkatMachine as Sets+Maps keyed by binding. Post-review that was
11//! rejected: auth lifecycle is orthogonal to MeerkatMachine's
12//! lifecycle, and carrying it there grew the TLC state space without
13//! unifying any semantics (dogma §19). Splitting it out per-binding
14//! keeps each machine small, aligns the fact "this binding's lease
15//! state" with one canonical owner per dogma §1, and decouples auth
16//! evolution from core-runtime evolution.
17
18use std::collections::HashMap;
19#[cfg(not(target_arch = "wasm32"))]
20use std::sync::Weak;
21use std::sync::{Arc, Mutex};
22
23#[cfg(not(target_arch = "wasm32"))]
24use meerkat_core::AuthBindingRef;
25use meerkat_core::handles::{
26    AuthLeaseHandle, AuthLeasePhase, AuthLeaseSnapshot, AuthLeaseTransition, DslTransitionError,
27    LeaseKey,
28};
29use meerkat_core::time_compat::{SystemTime, UNIX_EPOCH};
30
31use crate::auth_machine::dsl as auth_dsl;
32
33fn current_time_millis() -> u64 {
34    SystemTime::now()
35        .duration_since(UNIX_EPOCH)
36        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
37        .unwrap_or(0)
38}
39
40/// Emit a structured audit record for every accepted auth-lease DSL
41/// transition. REST/RPC surfaces (and any other `tracing::Subscriber`
42/// consumer) can filter on `target = "meerkat::auth::audit"` to build
43/// a persistent audit log without the shell inventing the fact set
44/// (dogma §17 — surfaces observe, they do not own lifecycle truth).
45///
46/// Deferral §5: structured observability for auth events. We emit at
47/// the single choke point where DSL transitions succeed, not from each
48/// caller — that keeps the audit trail aligned with the machine's
49/// actual acceptance of the transition and avoids duplication /
50/// drift (dogma §1).
51fn emit_audit(
52    lease_key: &LeaseKey,
53    action: &'static str,
54    from_phase: AuthLeasePhase,
55    to_phase: AuthLeasePhase,
56) {
57    tracing::info!(
58        target: "meerkat::auth::audit",
59        lease_key = %lease_key,
60        realm = %lease_key.realm,
61        binding = %lease_key.binding,
62        profile = lease_key.profile.as_ref().map(meerkat_core::ProfileId::as_str),
63        action = %action,
64        from_phase = ?from_phase,
65        to_phase = ?to_phase,
66        "auth lease transition"
67    );
68}
69
70/// Runtime-backed [`AuthLeaseHandle`] impl.
71///
72/// Holds a mutex-guarded registry of per-binding [`auth_dsl::AuthMachineAuthority`]
73/// instances. Lookup-or-insert happens on first `acquire_lease`; release is
74/// also allowed before acquire so token-clear surfaces remain idempotent after
75/// process restart.
76#[derive(Clone)]
77pub struct RuntimeAuthLeaseHandle {
78    machines: Arc<Mutex<AuthLeaseRegistry>>,
79    #[cfg(not(target_arch = "wasm32"))]
80    release_observers: Arc<Mutex<Vec<Weak<dyn AuthLeaseReleaseObserver>>>>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84#[derive(Debug, Clone)]
85pub(crate) struct ReleasedOAuthFlows {
86    pub lease_key: LeaseKey,
87    pub browser_flow_ids: Vec<String>,
88    pub device_flow_ids: Vec<String>,
89}
90
91#[cfg(not(target_arch = "wasm32"))]
92impl ReleasedOAuthFlows {
93    fn empty(lease_key: LeaseKey) -> Self {
94        Self {
95            lease_key,
96            browser_flow_ids: Vec::new(),
97            device_flow_ids: Vec::new(),
98        }
99    }
100
101    fn dedup(&mut self) {
102        self.browser_flow_ids.sort();
103        self.browser_flow_ids.dedup();
104        self.device_flow_ids.sort();
105        self.device_flow_ids.dedup();
106    }
107}
108
109fn has_oauth_membership(state: &auth_dsl::AuthMachineState) -> bool {
110    !state.oauth_browser_flow_ids.is_empty()
111        || !state.oauth_device_flow_ids.is_empty()
112        || !state.oauth_device_poll_ids.is_empty()
113}
114
115#[cfg(not(target_arch = "wasm32"))]
116fn merge_oauth_membership(
117    restored: &mut auth_dsl::AuthMachineState,
118    current: &auth_dsl::AuthMachineState,
119) {
120    for flow_id in &current.oauth_browser_flow_ids {
121        restored.oauth_browser_flow_ids.insert(flow_id.clone());
122        if let Some(provider) = current.oauth_browser_flow_providers.get(flow_id).cloned() {
123            restored
124                .oauth_browser_flow_providers
125                .insert(flow_id.clone(), provider);
126        }
127        if let Some(redirect_uri) = current
128            .oauth_browser_flow_redirect_uris
129            .get(flow_id)
130            .cloned()
131        {
132            restored
133                .oauth_browser_flow_redirect_uris
134                .insert(flow_id.clone(), redirect_uri);
135        }
136        if let Some(expires_at_millis) = current
137            .oauth_browser_flow_expires_at_millis
138            .get(flow_id)
139            .copied()
140        {
141            restored
142                .oauth_browser_flow_expires_at_millis
143                .insert(flow_id.clone(), expires_at_millis);
144        }
145    }
146    for flow_id in &current.oauth_device_flow_ids {
147        restored.oauth_device_flow_ids.insert(flow_id.clone());
148        if let Some(provider) = current.oauth_device_flow_providers.get(flow_id).cloned() {
149            restored
150                .oauth_device_flow_providers
151                .insert(flow_id.clone(), provider);
152        }
153        if let Some(expires_at_millis) = current
154            .oauth_device_flow_expires_at_millis
155            .get(flow_id)
156            .copied()
157        {
158            restored
159                .oauth_device_flow_expires_at_millis
160                .insert(flow_id.clone(), expires_at_millis);
161        }
162    }
163    for poll_id in &current.oauth_device_poll_ids {
164        restored.oauth_device_poll_ids.insert(poll_id.clone());
165    }
166    let browser_count = u64::try_from(restored.oauth_browser_flow_ids.len()).unwrap_or(u64::MAX);
167    let device_count = u64::try_from(restored.oauth_device_flow_ids.len()).unwrap_or(u64::MAX);
168    restored.oauth_outstanding_flow_count = browser_count.saturating_add(device_count);
169}
170
171#[cfg(not(target_arch = "wasm32"))]
172pub(crate) trait AuthLeaseReleaseObserver: Send + Sync {
173    fn oauth_flows_for_release(
174        &self,
175        lease_key: &LeaseKey,
176    ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
177        Ok(ReleasedOAuthFlows::empty(lease_key.clone()))
178    }
179
180    fn auth_lease_released(&self, released: &ReleasedOAuthFlows) -> Result<(), DslTransitionError>;
181}
182
183#[cfg(test)]
184pub(crate) type ReleaseAfterAcceptHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
185
186#[cfg(test)]
187static RELEASE_AFTER_ACCEPT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseAfterAcceptHook>>> =
188    std::sync::OnceLock::new();
189
190#[cfg(test)]
191static RELEASE_AFTER_ACCEPT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
192    std::sync::OnceLock::new();
193
194#[cfg(test)]
195pub(crate) struct ReleaseAfterAcceptHookGuard {
196    _serial: std::sync::MutexGuard<'static, ()>,
197}
198
199#[cfg(test)]
200impl Drop for ReleaseAfterAcceptHookGuard {
201    fn drop(&mut self) {
202        set_release_after_accept_hook_for_test(None);
203    }
204}
205
206#[cfg(test)]
207pub(crate) fn install_release_after_accept_hook_for_test(
208    hook: ReleaseAfterAcceptHook,
209) -> ReleaseAfterAcceptHookGuard {
210    let serial = RELEASE_AFTER_ACCEPT_HOOK_SERIAL
211        .get_or_init(|| Mutex::new(()))
212        .lock()
213        .unwrap_or_else(std::sync::PoisonError::into_inner);
214    set_release_after_accept_hook_for_test(Some(hook));
215    ReleaseAfterAcceptHookGuard { _serial: serial }
216}
217
218#[cfg(test)]
219fn set_release_after_accept_hook_for_test(hook: Option<ReleaseAfterAcceptHook>) {
220    *RELEASE_AFTER_ACCEPT_HOOK
221        .get_or_init(|| Mutex::new(None))
222        .lock()
223        .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
224}
225
226#[cfg(test)]
227fn run_release_after_accept_hook(lease_key: &LeaseKey) {
228    let hook = RELEASE_AFTER_ACCEPT_HOOK
229        .get_or_init(|| Mutex::new(None))
230        .lock()
231        .unwrap_or_else(std::sync::PoisonError::into_inner)
232        .clone();
233    if let Some(hook) = hook {
234        hook(lease_key);
235    }
236}
237
238#[derive(Default)]
239struct AuthLeaseRegistry {
240    authorities: HashMap<LeaseKey, auth_dsl::AuthMachineAuthority>,
241    // Projection version for AuthLeaseHandle::snapshot consumers. This is not
242    // lifecycle state; it is retained after release so authorizer-side token
243    // material can detect a later reacquire even when the expiry is identical.
244    generations: HashMap<LeaseKey, u64>,
245    credential_published_at_millis: HashMap<LeaseKey, u64>,
246}
247
248impl std::fmt::Debug for RuntimeAuthLeaseHandle {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        let guard = self
251            .machines
252            .lock()
253            .unwrap_or_else(std::sync::PoisonError::into_inner);
254        f.debug_struct("RuntimeAuthLeaseHandle")
255            .field("leases", &guard.authorities.keys().collect::<Vec<_>>())
256            .finish()
257    }
258}
259
260impl RuntimeAuthLeaseHandle {
261    pub fn new() -> Self {
262        Self {
263            machines: Arc::new(Mutex::new(AuthLeaseRegistry::default())),
264            #[cfg(not(target_arch = "wasm32"))]
265            release_observers: Arc::new(Mutex::new(Vec::new())),
266        }
267    }
268
269    /// Alias for [`Self::new`]; kept for parity with other runtime
270    /// handles that distinguish session-owned vs. ephemeral
271    /// constructors. AuthMachine instances are always ephemeral from
272    /// the registry's perspective — the registry itself is owned by
273    /// the session bindings.
274    pub fn ephemeral() -> Self {
275        Self::new()
276    }
277
278    #[cfg(not(target_arch = "wasm32"))]
279    pub(crate) fn add_release_observer(&self, observer: Weak<dyn AuthLeaseReleaseObserver>) {
280        self.release_observers
281            .lock()
282            .unwrap_or_else(std::sync::PoisonError::into_inner)
283            .push(observer);
284    }
285
286    #[cfg(not(target_arch = "wasm32"))]
287    fn live_release_observers(&self) -> Vec<Arc<dyn AuthLeaseReleaseObserver>> {
288        {
289            let mut guard = self
290                .release_observers
291                .lock()
292                .unwrap_or_else(std::sync::PoisonError::into_inner);
293            let mut observers = Vec::new();
294            guard.retain(|observer| match observer.upgrade() {
295                Some(observer) => {
296                    observers.push(observer);
297                    true
298                }
299                None => false,
300            });
301            observers
302        }
303    }
304
305    #[cfg(not(target_arch = "wasm32"))]
306    fn collect_release_observer_flows(
307        &self,
308        observers: &[Arc<dyn AuthLeaseReleaseObserver>],
309        lease_key: &LeaseKey,
310    ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
311        let mut released = ReleasedOAuthFlows::empty(lease_key.clone());
312        for observer in observers {
313            let mut observed = observer.oauth_flows_for_release(lease_key)?;
314            released
315                .browser_flow_ids
316                .append(&mut observed.browser_flow_ids);
317            released
318                .device_flow_ids
319                .append(&mut observed.device_flow_ids);
320        }
321        released.dedup();
322        Ok(released)
323    }
324
325    #[cfg(not(target_arch = "wasm32"))]
326    fn notify_release_observers(
327        &self,
328        observers: &[Arc<dyn AuthLeaseReleaseObserver>],
329        released: &ReleasedOAuthFlows,
330    ) -> Result<(), DslTransitionError> {
331        for observer in observers {
332            observer.auth_lease_released(released)?;
333        }
334        Ok(())
335    }
336
337    #[cfg(not(target_arch = "wasm32"))]
338    fn oauth_flow_bootstrap_state() -> auth_dsl::AuthMachineState {
339        auth_dsl::AuthMachineState {
340            lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
341            ..Default::default()
342        }
343    }
344
345    fn apply(
346        &self,
347        lease_key: &LeaseKey,
348        input: auth_dsl::AuthMachineInput,
349        context: &'static str,
350        create_if_missing: bool,
351    ) -> Result<AuthLeaseTransition, DslTransitionError> {
352        let action = Self::audit_action_for(&input);
353        let remove_after_accept = matches!(&input, auth_dsl::AuthMachineInput::Release);
354        let publishes_credential = matches!(
355            &input,
356            auth_dsl::AuthMachineInput::Acquire { .. }
357                | auth_dsl::AuthMachineInput::CompleteRefresh { .. }
358        );
359        let mut guard = self
360            .machines
361            .lock()
362            .unwrap_or_else(std::sync::PoisonError::into_inner);
363        if !create_if_missing
364            && guard.generations.get(lease_key).copied().unwrap_or(0) == 0
365            && !remove_after_accept
366        {
367            return Err(DslTransitionError::new(
368                context,
369                format!("no auth lease registered for lease_key `{lease_key}`"),
370            ));
371        }
372        let (from_phase, to_phase) = {
373            let entry = if create_if_missing {
374                guard
375                    .authorities
376                    .entry(lease_key.clone())
377                    .or_insert_with(|| {
378                        auth_dsl::AuthMachineAuthority::from_state(
379                            auth_dsl::AuthMachineState::default(),
380                        )
381                    })
382            } else {
383                match guard.authorities.get_mut(lease_key) {
384                    Some(m) => m,
385                    None => {
386                        return Err(DslTransitionError::new(
387                            context,
388                            format!("no auth lease registered for lease_key `{lease_key}`"),
389                        ));
390                    }
391                }
392            };
393            let from_phase = map_phase(entry.state.lifecycle_phase);
394            auth_dsl::AuthMachineMutator::apply(entry, input)
395                .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
396            let to_phase = map_phase(entry.state.lifecycle_phase);
397            (from_phase, to_phase)
398        };
399        let generation = guard.generations.entry(lease_key.clone()).or_insert(0);
400        if publishes_credential {
401            *generation = generation.saturating_add(1);
402        }
403        let accepted_generation = *generation;
404        let credential_published_at_millis = if publishes_credential {
405            let published_at = current_time_millis();
406            guard
407                .credential_published_at_millis
408                .insert(lease_key.clone(), published_at);
409            Some(published_at)
410        } else {
411            guard.credential_published_at_millis.get(lease_key).copied()
412        };
413        if remove_after_accept {
414            guard.authorities.remove(lease_key);
415            guard.credential_published_at_millis.remove(lease_key);
416        }
417        emit_audit(lease_key, action, from_phase, to_phase);
418        Ok(AuthLeaseTransition::new(
419            accepted_generation,
420            credential_published_at_millis,
421        ))
422    }
423
424    #[cfg(not(target_arch = "wasm32"))]
425    pub(crate) fn apply_oauth_input(
426        &self,
427        target: &AuthBindingRef,
428        input: auth_dsl::AuthMachineInput,
429        context: &'static str,
430        create_if_missing: bool,
431    ) -> Result<(), DslTransitionError> {
432        let lease_key = LeaseKey::from_auth_binding(target);
433        let action = Self::audit_action_for(&input);
434        let mut guard = self
435            .machines
436            .lock()
437            .unwrap_or_else(std::sync::PoisonError::into_inner);
438        if let Some((transition, max_outstanding_flows)) = match &input {
439            auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
440                max_outstanding_flows,
441                ..
442            } => Some(("AdmitOAuthBrowserFlow", *max_outstanding_flows)),
443            auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
444                max_outstanding_flows,
445                ..
446            } => Some(("AdmitOAuthDeviceFlow", *max_outstanding_flows)),
447            _ => None,
448        } {
449            let outstanding = guard
450                .authorities
451                .values()
452                .map(|authority| authority.state.oauth_outstanding_flow_count)
453                .fold(0u64, u64::saturating_add);
454            if outstanding >= max_outstanding_flows {
455                return Err(DslTransitionError::guard_rejected(
456                    context,
457                    format!(
458                        "transition {transition} guard oauth_global_capacity_available failed: {outstanding} outstanding OAuth flows >= {max_outstanding_flows} max"
459                    ),
460                ));
461            }
462        }
463        let (from_phase, to_phase) = {
464            let entry = if create_if_missing {
465                guard
466                    .authorities
467                    .entry(lease_key.clone())
468                    .or_insert_with(|| {
469                        auth_dsl::AuthMachineAuthority::from_state(
470                            Self::oauth_flow_bootstrap_state(),
471                        )
472                    })
473            } else {
474                match guard.authorities.get_mut(&lease_key) {
475                    Some(m) => m,
476                    None => {
477                        return Err(DslTransitionError::new(
478                            context,
479                            format!("no auth machine registered for lease_key `{lease_key}`"),
480                        ));
481                    }
482                }
483            };
484            let from_phase = map_phase(entry.state.lifecycle_phase);
485            auth_dsl::AuthMachineMutator::apply(entry, input)
486                .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
487            let to_phase = map_phase(entry.state.lifecycle_phase);
488            (from_phase, to_phase)
489        };
490        emit_audit(&lease_key, action, from_phase, to_phase);
491        Ok(())
492    }
493
494    #[cfg(not(target_arch = "wasm32"))]
495    pub(crate) fn has_oauth_browser_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
496        let lease_key = LeaseKey::from_auth_binding(target);
497        self.machines
498            .lock()
499            .unwrap_or_else(std::sync::PoisonError::into_inner)
500            .authorities
501            .get(&lease_key)
502            .is_some_and(|authority| authority.state.oauth_browser_flow_ids.contains(flow_id))
503    }
504
505    #[cfg(not(target_arch = "wasm32"))]
506    pub(crate) fn has_oauth_device_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
507        let lease_key = LeaseKey::from_auth_binding(target);
508        self.machines
509            .lock()
510            .unwrap_or_else(std::sync::PoisonError::into_inner)
511            .authorities
512            .get(&lease_key)
513            .is_some_and(|authority| authority.state.oauth_device_flow_ids.contains(flow_id))
514    }
515
516    #[cfg(test)]
517    pub(crate) fn has_oauth_browser_flow_for_test(
518        &self,
519        target: &AuthBindingRef,
520        flow_id: &str,
521    ) -> bool {
522        self.has_oauth_browser_flow(target, flow_id)
523    }
524
525    #[cfg(test)]
526    pub(crate) fn has_oauth_device_flow_for_test(
527        &self,
528        target: &AuthBindingRef,
529        flow_id: &str,
530    ) -> bool {
531        self.has_oauth_device_flow(target, flow_id)
532    }
533
534    #[cfg(not(target_arch = "wasm32"))]
535    fn restore_released_lease_after_observer_failure(
536        &self,
537        lease_key: &LeaseKey,
538        previous_state: Option<auth_dsl::AuthMachineState>,
539        previous_generation: Option<u64>,
540        previous_published_at: Option<u64>,
541    ) {
542        let mut guard = self
543            .machines
544            .lock()
545            .unwrap_or_else(std::sync::PoisonError::into_inner);
546        let current_generation = guard.generations.get(lease_key).copied().unwrap_or(0);
547        let previous_generation_value = previous_generation.unwrap_or(0);
548        if current_generation > previous_generation_value {
549            let to_phase = match previous_state {
550                Some(previous_state) => {
551                    if let Some(current) = guard.authorities.get(lease_key) {
552                        let mut current_state = current.state.clone();
553                        merge_oauth_membership(&mut current_state, &previous_state);
554                        let to_phase = map_phase(current_state.lifecycle_phase);
555                        guard.authorities.insert(
556                            lease_key.clone(),
557                            auth_dsl::AuthMachineAuthority::from_state(current_state),
558                        );
559                        to_phase
560                    } else if has_oauth_membership(&previous_state) {
561                        guard.authorities.insert(
562                            lease_key.clone(),
563                            auth_dsl::AuthMachineAuthority::from_state(
564                                auth_dsl::AuthMachineState {
565                                    lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
566                                    credential_present: false,
567                                    oauth_browser_flow_ids: previous_state.oauth_browser_flow_ids,
568                                    oauth_browser_flow_providers: previous_state
569                                        .oauth_browser_flow_providers,
570                                    oauth_browser_flow_redirect_uris: previous_state
571                                        .oauth_browser_flow_redirect_uris,
572                                    oauth_browser_flow_expires_at_millis: previous_state
573                                        .oauth_browser_flow_expires_at_millis,
574                                    oauth_device_flow_ids: previous_state.oauth_device_flow_ids,
575                                    oauth_device_flow_providers: previous_state
576                                        .oauth_device_flow_providers,
577                                    oauth_device_flow_expires_at_millis: previous_state
578                                        .oauth_device_flow_expires_at_millis,
579                                    oauth_device_poll_ids: previous_state.oauth_device_poll_ids,
580                                    oauth_outstanding_flow_count: previous_state
581                                        .oauth_outstanding_flow_count,
582                                    ..Default::default()
583                                },
584                            ),
585                        );
586                        AuthLeasePhase::ReauthRequired
587                    } else {
588                        AuthLeasePhase::Released
589                    }
590                }
591                None => guard
592                    .authorities
593                    .get(lease_key)
594                    .map(|current| map_phase(current.state.lifecycle_phase))
595                    .unwrap_or(AuthLeasePhase::Released),
596            };
597            drop(guard);
598            emit_audit(
599                lease_key,
600                "rollback_release_lease",
601                AuthLeasePhase::Released,
602                to_phase,
603            );
604            return;
605        }
606        let to_phase = if let Some(mut restored_state) = previous_state {
607            if let Some(current) = guard.authorities.get(lease_key) {
608                if current.state.credential_present {
609                    let mut current_state = current.state.clone();
610                    merge_oauth_membership(&mut current_state, &restored_state);
611                    let to_phase = map_phase(current_state.lifecycle_phase);
612                    guard.authorities.insert(
613                        lease_key.clone(),
614                        auth_dsl::AuthMachineAuthority::from_state(current_state),
615                    );
616                    drop(guard);
617                    emit_audit(
618                        lease_key,
619                        "rollback_release_lease",
620                        AuthLeasePhase::Released,
621                        to_phase,
622                    );
623                    return;
624                }
625                merge_oauth_membership(&mut restored_state, &current.state);
626            }
627            let to_phase = map_phase(restored_state.lifecycle_phase);
628            guard.authorities.insert(
629                lease_key.clone(),
630                auth_dsl::AuthMachineAuthority::from_state(restored_state),
631            );
632            match previous_generation {
633                Some(generation) => {
634                    guard.generations.insert(lease_key.clone(), generation);
635                }
636                None => {
637                    guard.generations.remove(lease_key);
638                }
639            }
640            match previous_published_at {
641                Some(published_at) => {
642                    guard
643                        .credential_published_at_millis
644                        .insert(lease_key.clone(), published_at);
645                }
646                None => {
647                    guard.credential_published_at_millis.remove(lease_key);
648                }
649            }
650            to_phase
651        } else if let Some(current) = guard.authorities.get(lease_key) {
652            map_phase(current.state.lifecycle_phase)
653        } else {
654            guard.authorities.remove(lease_key);
655            match previous_generation {
656                Some(generation) => {
657                    guard.generations.insert(lease_key.clone(), generation);
658                }
659                None => {
660                    guard.generations.remove(lease_key);
661                }
662            }
663            match previous_published_at {
664                Some(published_at) => {
665                    guard
666                        .credential_published_at_millis
667                        .insert(lease_key.clone(), published_at);
668                }
669                None => {
670                    guard.credential_published_at_millis.remove(lease_key);
671                }
672            }
673            AuthLeasePhase::Released
674        };
675        drop(guard);
676        emit_audit(
677            lease_key,
678            "rollback_release_lease",
679            AuthLeasePhase::Released,
680            to_phase,
681        );
682    }
683
684    fn audit_action_for(input: &auth_dsl::AuthMachineInput) -> &'static str {
685        match input {
686            auth_dsl::AuthMachineInput::Acquire { .. } => "acquire_lease",
687            auth_dsl::AuthMachineInput::MarkExpiring => "mark_expiring",
688            auth_dsl::AuthMachineInput::BeginRefresh => "begin_refresh",
689            auth_dsl::AuthMachineInput::CompleteRefresh { .. } => "complete_refresh",
690            auth_dsl::AuthMachineInput::RefreshFailedTransient => "refresh_failed_transient",
691            auth_dsl::AuthMachineInput::RefreshFailedPermanent => "refresh_failed_permanent",
692            auth_dsl::AuthMachineInput::MarkReauthRequired => "mark_reauth_required",
693            auth_dsl::AuthMachineInput::ClearCredentialLifecycle => "clear_credential_lifecycle",
694            auth_dsl::AuthMachineInput::Release => "release_lease",
695            auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow { .. } => "admit_oauth_browser_flow",
696            auth_dsl::AuthMachineInput::VerifyOAuthBrowserFlow { .. } => {
697                "verify_oauth_browser_flow"
698            }
699            auth_dsl::AuthMachineInput::ConsumeOAuthBrowserFlow { .. } => {
700                "consume_oauth_browser_flow"
701            }
702            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { .. } => {
703                "expire_oauth_browser_flow"
704            }
705            auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow { .. } => "admit_oauth_device_flow",
706            auth_dsl::AuthMachineInput::VerifyOAuthDeviceFlow { .. } => "verify_oauth_device_flow",
707            auth_dsl::AuthMachineInput::BeginOAuthDevicePoll { .. } => "begin_oauth_device_poll",
708            auth_dsl::AuthMachineInput::FinishOAuthDevicePoll { .. } => "finish_oauth_device_poll",
709            auth_dsl::AuthMachineInput::ConsumeOAuthDeviceFlow { .. } => {
710                "consume_oauth_device_flow"
711            }
712            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { .. } => "expire_oauth_device_flow",
713        }
714    }
715}
716
717/// Exhaustive 1-to-1 projection of the AuthMachine DSL's typed lifecycle
718/// phase into the cross-crate [`AuthLeasePhase`] contract. Compiler enforces
719/// completeness.
720fn map_phase(phase: auth_dsl::AuthLifecyclePhase) -> AuthLeasePhase {
721    match phase {
722        auth_dsl::AuthLifecyclePhase::Valid => AuthLeasePhase::Valid,
723        auth_dsl::AuthLifecyclePhase::Expiring => AuthLeasePhase::Expiring,
724        auth_dsl::AuthLifecyclePhase::Refreshing => AuthLeasePhase::Refreshing,
725        auth_dsl::AuthLifecyclePhase::ReauthRequired => AuthLeasePhase::ReauthRequired,
726        auth_dsl::AuthLifecyclePhase::Released => AuthLeasePhase::Released,
727    }
728}
729
730fn restore_phase(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
731    match phase {
732        AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
733        AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
734        AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
735        AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
736        AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
737    }
738}
739
740impl Default for RuntimeAuthLeaseHandle {
741    fn default() -> Self {
742        Self::new()
743    }
744}
745
746impl AuthLeaseHandle for RuntimeAuthLeaseHandle {
747    fn acquire_lease(
748        &self,
749        lease_key: &LeaseKey,
750        expires_at: u64,
751    ) -> Result<AuthLeaseTransition, DslTransitionError> {
752        let expires_at_ts = if expires_at == u64::MAX {
753            None
754        } else {
755            Some(expires_at)
756        };
757        self.apply(
758            lease_key,
759            auth_dsl::AuthMachineInput::Acquire { expires_at_ts },
760            "AuthLeaseHandle::acquire_lease",
761            true,
762        )
763    }
764
765    fn mark_expiring(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
766        self.apply(
767            lease_key,
768            auth_dsl::AuthMachineInput::MarkExpiring,
769            "AuthLeaseHandle::mark_expiring",
770            false,
771        )
772        .map(|_| ())
773    }
774
775    fn begin_refresh(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
776        self.apply(
777            lease_key,
778            auth_dsl::AuthMachineInput::BeginRefresh,
779            "AuthLeaseHandle::begin_refresh",
780            false,
781        )
782        .map(|_| ())
783    }
784
785    fn complete_refresh(
786        &self,
787        lease_key: &LeaseKey,
788        new_expires_at: u64,
789        now: u64,
790    ) -> Result<AuthLeaseTransition, DslTransitionError> {
791        let new_expires_at = if new_expires_at == u64::MAX {
792            None
793        } else {
794            Some(new_expires_at)
795        };
796        self.apply(
797            lease_key,
798            auth_dsl::AuthMachineInput::CompleteRefresh {
799                new_expires_at,
800                now_ts: now,
801            },
802            "AuthLeaseHandle::complete_refresh",
803            false,
804        )
805    }
806
807    fn refresh_failed(
808        &self,
809        lease_key: &LeaseKey,
810        permanent: bool,
811    ) -> Result<(), DslTransitionError> {
812        let input = if permanent {
813            auth_dsl::AuthMachineInput::RefreshFailedPermanent
814        } else {
815            auth_dsl::AuthMachineInput::RefreshFailedTransient
816        };
817        self.apply(lease_key, input, "AuthLeaseHandle::refresh_failed", false)
818            .map(|_| ())
819    }
820
821    fn mark_reauth_required(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
822        self.apply(
823            lease_key,
824            auth_dsl::AuthMachineInput::MarkReauthRequired,
825            "AuthLeaseHandle::mark_reauth_required",
826            false,
827        )
828        .map(|_| ())
829    }
830
831    fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
832        let context = "AuthLeaseHandle::release_lease";
833        // Capture OAuth membership while the released machine is still the
834        // canonical AuthMachine state. Observers later prune only those exact
835        // payloads, so a same-target flow admitted after this transition is
836        // not removed by stale target-wide cleanup.
837        #[cfg(not(target_arch = "wasm32"))]
838        let release_observers = self.live_release_observers();
839        #[cfg(not(target_arch = "wasm32"))]
840        let mut released = self.collect_release_observer_flows(&release_observers, lease_key)?;
841        #[cfg(not(target_arch = "wasm32"))]
842        let previous_state;
843        #[cfg(not(target_arch = "wasm32"))]
844        let previous_generation;
845        #[cfg(not(target_arch = "wasm32"))]
846        let previous_published_at;
847        let (from_phase, to_phase) = {
848            let mut guard = self
849                .machines
850                .lock()
851                .unwrap_or_else(std::sync::PoisonError::into_inner);
852            #[cfg(not(target_arch = "wasm32"))]
853            {
854                previous_generation = guard.generations.get(lease_key).copied();
855                previous_published_at =
856                    guard.credential_published_at_millis.get(lease_key).copied();
857                previous_state = guard
858                    .authorities
859                    .get(lease_key)
860                    .map(|authority| authority.state.clone());
861            }
862            let entry =
863                guard
864                    .authorities
865                    .entry(lease_key.clone())
866                    .or_insert_with(|| {
867                        auth_dsl::AuthMachineAuthority::from_state(
868                            auth_dsl::AuthMachineState::default(),
869                        )
870                    });
871            #[cfg(not(target_arch = "wasm32"))]
872            {
873                released
874                    .browser_flow_ids
875                    .extend(entry.state.oauth_browser_flow_ids.iter().cloned());
876                released
877                    .device_flow_ids
878                    .extend(entry.state.oauth_device_flow_ids.iter().cloned());
879                released.dedup();
880            }
881            let from_phase = map_phase(entry.state.lifecycle_phase);
882            auth_dsl::AuthMachineMutator::apply(entry, auth_dsl::AuthMachineInput::Release)
883                .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
884            let to_phase = map_phase(entry.state.lifecycle_phase);
885            guard.generations.entry(lease_key.clone()).or_insert(0);
886            guard.authorities.remove(lease_key);
887            guard.credential_published_at_millis.remove(lease_key);
888            (from_phase, to_phase)
889        };
890        emit_audit(lease_key, "release_lease", from_phase, to_phase);
891        #[cfg(test)]
892        run_release_after_accept_hook(lease_key);
893        #[cfg(not(target_arch = "wasm32"))]
894        if let Err(err) = self.notify_release_observers(&release_observers, &released) {
895            self.restore_released_lease_after_observer_failure(
896                lease_key,
897                previous_state,
898                previous_generation,
899                previous_published_at,
900            );
901            return Err(err);
902        }
903        Ok(())
904    }
905
906    fn release_credential_lifecycle(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
907        let context = "AuthLeaseHandle::release_credential_lifecycle";
908        let mut guard = self
909            .machines
910            .lock()
911            .unwrap_or_else(std::sync::PoisonError::into_inner);
912        let (input, remove_after_accept, from_phase, to_phase) = {
913            let entry =
914                guard
915                    .authorities
916                    .entry(lease_key.clone())
917                    .or_insert_with(|| {
918                        auth_dsl::AuthMachineAuthority::from_state(
919                            auth_dsl::AuthMachineState::default(),
920                        )
921                    });
922            let has_oauth_membership = has_oauth_membership(&entry.state);
923            let input = if has_oauth_membership {
924                auth_dsl::AuthMachineInput::ClearCredentialLifecycle
925            } else {
926                auth_dsl::AuthMachineInput::Release
927            };
928            let remove_after_accept = matches!(&input, auth_dsl::AuthMachineInput::Release);
929            let from_phase = map_phase(entry.state.lifecycle_phase);
930            auth_dsl::AuthMachineMutator::apply(entry, input.clone())
931                .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
932            let to_phase = map_phase(entry.state.lifecycle_phase);
933            (input, remove_after_accept, from_phase, to_phase)
934        };
935        guard.credential_published_at_millis.remove(lease_key);
936        if remove_after_accept {
937            guard.authorities.remove(lease_key);
938        }
939        emit_audit(
940            lease_key,
941            Self::audit_action_for(&input),
942            from_phase,
943            to_phase,
944        );
945        Ok(())
946    }
947
948    fn restore_auth_lifecycle_snapshot(
949        &self,
950        lease_key: &LeaseKey,
951        snapshot: &AuthLeaseSnapshot,
952        expires_at: Option<u64>,
953    ) -> Result<(), DslTransitionError> {
954        let mut guard = self
955            .machines
956            .lock()
957            .unwrap_or_else(std::sync::PoisonError::into_inner);
958        let current_generation = guard.generations.get(lease_key).copied().unwrap_or(0);
959        let from_phase = guard
960            .authorities
961            .get(lease_key)
962            .map(|authority| map_phase(authority.state.lifecycle_phase))
963            .unwrap_or(AuthLeasePhase::Released);
964        let existing_oauth = guard
965            .authorities
966            .get(lease_key)
967            .map(|authority| authority.state.clone());
968        let has_oauth_membership = existing_oauth.as_ref().is_some_and(has_oauth_membership);
969
970        if !snapshot.credential_present
971            || snapshot.phase.is_none()
972            || snapshot.phase == Some(AuthLeasePhase::Released)
973        {
974            guard.credential_published_at_millis.remove(lease_key);
975            if has_oauth_membership {
976                let oauth = existing_oauth.unwrap_or_default();
977                guard.authorities.insert(
978                    lease_key.clone(),
979                    auth_dsl::AuthMachineAuthority::from_state(auth_dsl::AuthMachineState {
980                        lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
981                        credential_present: false,
982                        oauth_browser_flow_ids: oauth.oauth_browser_flow_ids,
983                        oauth_browser_flow_providers: oauth.oauth_browser_flow_providers,
984                        oauth_browser_flow_redirect_uris: oauth.oauth_browser_flow_redirect_uris,
985                        oauth_browser_flow_expires_at_millis: oauth
986                            .oauth_browser_flow_expires_at_millis,
987                        oauth_device_flow_ids: oauth.oauth_device_flow_ids,
988                        oauth_device_flow_providers: oauth.oauth_device_flow_providers,
989                        oauth_device_flow_expires_at_millis: oauth
990                            .oauth_device_flow_expires_at_millis,
991                        oauth_device_poll_ids: oauth.oauth_device_poll_ids,
992                        oauth_outstanding_flow_count: oauth.oauth_outstanding_flow_count,
993                        ..Default::default()
994                    }),
995                );
996                guard.generations.insert(
997                    lease_key.clone(),
998                    current_generation.max(snapshot.generation),
999                );
1000            } else {
1001                guard.authorities.remove(lease_key);
1002                if snapshot.generation == 0 {
1003                    guard.generations.remove(lease_key);
1004                } else {
1005                    guard
1006                        .generations
1007                        .insert(lease_key.clone(), snapshot.generation);
1008                }
1009            }
1010            emit_audit(
1011                lease_key,
1012                "restore_auth_lifecycle_snapshot",
1013                from_phase,
1014                AuthLeasePhase::ReauthRequired,
1015            );
1016            return Ok(());
1017        }
1018
1019        let Some(phase) = snapshot.phase else {
1020            return Ok(());
1021        };
1022        let Some(expires_at) = expires_at else {
1023            return Ok(());
1024        };
1025        let expires_at = if expires_at == u64::MAX {
1026            None
1027        } else {
1028            Some(expires_at)
1029        };
1030        let oauth = existing_oauth.unwrap_or_default();
1031        guard.authorities.insert(
1032            lease_key.clone(),
1033            auth_dsl::AuthMachineAuthority::from_state(auth_dsl::AuthMachineState {
1034                lifecycle_phase: restore_phase(phase),
1035                expires_at,
1036                credential_present: true,
1037                oauth_browser_flow_ids: oauth.oauth_browser_flow_ids,
1038                oauth_browser_flow_providers: oauth.oauth_browser_flow_providers,
1039                oauth_browser_flow_redirect_uris: oauth.oauth_browser_flow_redirect_uris,
1040                oauth_browser_flow_expires_at_millis: oauth.oauth_browser_flow_expires_at_millis,
1041                oauth_device_flow_ids: oauth.oauth_device_flow_ids,
1042                oauth_device_flow_providers: oauth.oauth_device_flow_providers,
1043                oauth_device_flow_expires_at_millis: oauth.oauth_device_flow_expires_at_millis,
1044                oauth_device_poll_ids: oauth.oauth_device_poll_ids,
1045                oauth_outstanding_flow_count: oauth.oauth_outstanding_flow_count,
1046                ..Default::default()
1047            }),
1048        );
1049        guard.generations.insert(
1050            lease_key.clone(),
1051            current_generation.max(snapshot.generation),
1052        );
1053        match snapshot.credential_published_at_millis {
1054            Some(published_at) => {
1055                guard
1056                    .credential_published_at_millis
1057                    .insert(lease_key.clone(), published_at);
1058            }
1059            None => {
1060                guard.credential_published_at_millis.remove(lease_key);
1061            }
1062        }
1063        emit_audit(
1064            lease_key,
1065            "restore_auth_lifecycle_snapshot",
1066            from_phase,
1067            phase,
1068        );
1069        Ok(())
1070    }
1071
1072    fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot {
1073        let guard = self
1074            .machines
1075            .lock()
1076            .unwrap_or_else(std::sync::PoisonError::into_inner);
1077        let generation = guard.generations.get(lease_key).copied().unwrap_or(0);
1078        match guard.authorities.get(lease_key) {
1079            Some(machine) => AuthLeaseSnapshot {
1080                phase: Some(map_phase(machine.state.lifecycle_phase)),
1081                expires_at: machine.state.expires_at,
1082                credential_present: machine.state.credential_present,
1083                generation,
1084                credential_published_at_millis: machine
1085                    .state
1086                    .credential_present
1087                    .then(|| guard.credential_published_at_millis.get(lease_key).copied())
1088                    .flatten(),
1089            },
1090            None => AuthLeaseSnapshot {
1091                phase: None,
1092                expires_at: None,
1093                credential_present: false,
1094                generation,
1095                credential_published_at_millis: None,
1096            },
1097        }
1098    }
1099}
1100
1101#[cfg(test)]
1102#[allow(clippy::unwrap_used, clippy::expect_used)]
1103mod tests {
1104    use super::*;
1105    use meerkat_core::connection::{BindingId, RealmId};
1106
1107    fn lease(realm: &str, binding: &str) -> LeaseKey {
1108        LeaseKey::new(
1109            RealmId::parse(realm).expect("valid realm"),
1110            BindingId::parse(binding).expect("valid binding"),
1111            None,
1112        )
1113    }
1114
1115    #[test]
1116    fn acquire_and_snapshot_roundtrip() {
1117        let h = RuntimeAuthLeaseHandle::new();
1118        let key = lease("dev", "default_openai");
1119        h.acquire_lease(&key, 1_800_000_000).unwrap();
1120        let snap = h.snapshot(&key);
1121        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1122        assert_eq!(snap.expires_at, Some(1_800_000_000));
1123    }
1124
1125    #[test]
1126    fn lifecycle_transitions() {
1127        let h = RuntimeAuthLeaseHandle::new();
1128        let k = lease("dev", "default_anthropic");
1129
1130        h.acquire_lease(&k, 1_800_000_000).unwrap();
1131        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1132
1133        h.mark_expiring(&k).unwrap();
1134        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1135
1136        h.begin_refresh(&k).unwrap();
1137        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1138
1139        h.complete_refresh(&k, 1_800_000_900, 1_800_000_000)
1140            .unwrap();
1141        let snap = h.snapshot(&k);
1142        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1143        assert_eq!(snap.expires_at, Some(1_800_000_900));
1144    }
1145
1146    #[test]
1147    fn refresh_failed_permanent_routes_to_reauth() {
1148        let h = RuntimeAuthLeaseHandle::new();
1149        let k = lease("dev", "default_google");
1150
1151        h.acquire_lease(&k, 1_800_000_000).unwrap();
1152        h.begin_refresh(&k).unwrap();
1153        h.refresh_failed(&k, true).unwrap();
1154
1155        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::ReauthRequired));
1156    }
1157
1158    #[test]
1159    fn refresh_failed_transient_routes_to_expiring() {
1160        let h = RuntimeAuthLeaseHandle::new();
1161        let k = lease("dev", "foo");
1162
1163        h.acquire_lease(&k, 1_800_000_000).unwrap();
1164        h.begin_refresh(&k).unwrap();
1165        h.refresh_failed(&k, false).unwrap();
1166
1167        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1168    }
1169
1170    #[test]
1171    fn transient_refresh_failure_preserves_credential_marker_generation() {
1172        let h = RuntimeAuthLeaseHandle::new();
1173        let k = lease("dev", "retryable_refresh");
1174
1175        h.acquire_lease(&k, 1_800_000_000).unwrap();
1176        let before = h.snapshot(&k);
1177        h.begin_refresh(&k).unwrap();
1178        h.refresh_failed(&k, false).unwrap();
1179        let after = h.snapshot(&k);
1180
1181        assert_eq!(after.phase, Some(AuthLeasePhase::Expiring));
1182        assert_eq!(after.expires_at, before.expires_at);
1183        assert_eq!(after.generation, before.generation);
1184        assert_eq!(
1185            after.credential_published_at_millis,
1186            before.credential_published_at_millis
1187        );
1188    }
1189
1190    #[test]
1191    fn snapshot_for_unknown_binding_is_none() {
1192        let h = RuntimeAuthLeaseHandle::new();
1193        let snap = h.snapshot(&lease("dev", "never_registered"));
1194        assert!(snap.phase.is_none());
1195        assert!(snap.expires_at.is_none());
1196    }
1197
1198    #[test]
1199    fn mark_expiring_before_acquire_errors() {
1200        let h = RuntimeAuthLeaseHandle::new();
1201        let err = h.mark_expiring(&lease("dev", "ghost")).unwrap_err();
1202        assert_eq!(err.context, "AuthLeaseHandle::mark_expiring");
1203    }
1204
1205    #[test]
1206    fn release_before_acquire_is_idempotent() {
1207        let h = RuntimeAuthLeaseHandle::new();
1208        let key = lease("dev", "ghost");
1209
1210        h.release_lease(&key).unwrap();
1211
1212        let snap = h.snapshot(&key);
1213        assert!(snap.phase.is_none());
1214        assert!(snap.expires_at.is_none());
1215    }
1216
1217    #[test]
1218    fn release_does_not_remove_concurrent_reacquire() {
1219        let h = RuntimeAuthLeaseHandle::new();
1220        let key = lease("dev", "shared");
1221        h.acquire_lease(&key, 1_800_000_000).unwrap();
1222
1223        let acquire_handle = h.clone();
1224        let acquire_key = key.clone();
1225        let hook_key = key.clone();
1226        let acquired_generation = Arc::new(Mutex::new(None));
1227        let hook_generation = Arc::clone(&acquired_generation);
1228        let _hook_guard =
1229            install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1230                if released_key != &hook_key {
1231                    return;
1232                }
1233                let generation = acquire_handle
1234                    .acquire_lease(&acquire_key, 1_800_000_000)
1235                    .unwrap()
1236                    .generation;
1237                *hook_generation.lock().unwrap() = Some(generation);
1238            }));
1239
1240        h.release_lease(&key).unwrap();
1241
1242        let acquired_generation = acquired_generation
1243            .lock()
1244            .unwrap()
1245            .expect("release hook should reacquire the lease");
1246
1247        let snap = h.snapshot(&key);
1248        assert_eq!(
1249            snap.phase,
1250            Some(AuthLeasePhase::Valid),
1251            "accepted reacquire generation {acquired_generation} must remain visible after release completes; snapshot was {snap:?}"
1252        );
1253        assert_eq!(snap.expires_at, Some(1_800_000_000));
1254        assert_eq!(snap.generation, acquired_generation);
1255    }
1256
1257    #[cfg(not(target_arch = "wasm32"))]
1258    struct FailingReleaseObserver;
1259
1260    #[cfg(not(target_arch = "wasm32"))]
1261    impl AuthLeaseReleaseObserver for FailingReleaseObserver {
1262        fn auth_lease_released(
1263            &self,
1264            _released: &ReleasedOAuthFlows,
1265        ) -> Result<(), DslTransitionError> {
1266            Err(DslTransitionError::new(
1267                "test_release_observer",
1268                "injected release observer failure",
1269            ))
1270        }
1271    }
1272
1273    #[cfg(not(target_arch = "wasm32"))]
1274    #[test]
1275    fn release_observer_failure_does_not_overwrite_concurrent_reacquire() {
1276        let h = RuntimeAuthLeaseHandle::new();
1277        let key = lease("dev", "shared_failure");
1278        h.acquire_lease(&key, 1_800_000_000).unwrap();
1279
1280        let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
1281        h.add_release_observer(Arc::downgrade(&observer));
1282
1283        let acquire_handle = h.clone();
1284        let acquire_key = key.clone();
1285        let hook_key = key.clone();
1286        let acquired_transition = Arc::new(Mutex::new(None));
1287        let hook_transition = Arc::clone(&acquired_transition);
1288        let _hook_guard =
1289            install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1290                if released_key != &hook_key {
1291                    return;
1292                }
1293                let transition = acquire_handle
1294                    .acquire_lease(&acquire_key, 1_900_000_000)
1295                    .unwrap();
1296                *hook_transition.lock().unwrap() = Some(transition);
1297            }));
1298
1299        assert!(h.release_lease(&key).is_err());
1300
1301        let acquired_transition = acquired_transition
1302            .lock()
1303            .unwrap()
1304            .expect("release hook should reacquire the lease");
1305        let snap = h.snapshot(&key);
1306        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1307        assert_eq!(
1308            snap.expires_at,
1309            Some(1_900_000_000),
1310            "observer-failure rollback must not restore the stale released credential snapshot"
1311        );
1312        assert_eq!(snap.generation, acquired_transition.generation);
1313        assert_eq!(
1314            snap.credential_published_at_millis,
1315            acquired_transition.credential_published_at_millis
1316        );
1317    }
1318
1319    #[cfg(not(target_arch = "wasm32"))]
1320    #[test]
1321    fn release_observer_failure_does_not_resurrect_after_newer_release() {
1322        let h = RuntimeAuthLeaseHandle::new();
1323        let key = lease("dev", "shared_newer_release");
1324        h.acquire_lease(&key, 1_800_000_000).unwrap();
1325
1326        let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
1327        h.add_release_observer(Arc::downgrade(&observer));
1328
1329        let lifecycle_handle = h.clone();
1330        let lifecycle_key = key.clone();
1331        let hook_key = key.clone();
1332        let acquired_transition = Arc::new(Mutex::new(None));
1333        let hook_transition = Arc::clone(&acquired_transition);
1334        let _hook_guard =
1335            install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1336                if released_key != &hook_key {
1337                    return;
1338                }
1339                let transition = lifecycle_handle
1340                    .acquire_lease(&lifecycle_key, 1_900_000_000)
1341                    .unwrap();
1342                lifecycle_handle
1343                    .release_credential_lifecycle(&lifecycle_key)
1344                    .unwrap();
1345                *hook_transition.lock().unwrap() = Some(transition);
1346            }));
1347
1348        assert!(h.release_lease(&key).is_err());
1349
1350        let acquired_transition = acquired_transition
1351            .lock()
1352            .unwrap()
1353            .expect("release hook should reacquire before newer release");
1354        let snap = h.snapshot(&key);
1355        assert_eq!(
1356            snap.phase, None,
1357            "older observer-failure rollback must not resurrect stale credential state after a newer release"
1358        );
1359        assert_eq!(snap.expires_at, None);
1360        assert_eq!(snap.generation, acquired_transition.generation);
1361        assert_eq!(snap.credential_published_at_millis, None);
1362    }
1363
1364    #[test]
1365    fn repeated_acquire_updates_existing_lease() {
1366        let h = RuntimeAuthLeaseHandle::new();
1367        let key = lease("dev", "default");
1368
1369        h.acquire_lease(&key, 1_800_000_000).unwrap();
1370        h.acquire_lease(&key, 1_900_000_000).unwrap();
1371
1372        let snap = h.snapshot(&key);
1373        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1374        assert_eq!(snap.expires_at, Some(1_900_000_000));
1375    }
1376
1377    #[test]
1378    fn restore_snapshot_preserves_publication_marker_without_lowering_generation() {
1379        let h = RuntimeAuthLeaseHandle::new();
1380        let key = lease("dev", "shared");
1381
1382        h.acquire_lease(&key, 1_800_000_000).unwrap();
1383        let before = h.snapshot(&key);
1384        assert_eq!(before.phase, Some(AuthLeasePhase::Valid));
1385        assert!(before.credential_present);
1386        assert!(before.credential_published_at_millis.is_some());
1387
1388        h.acquire_lease(&key, 1_900_000_000).unwrap();
1389        let advanced = h.snapshot(&key);
1390        assert!(advanced.generation > before.generation);
1391
1392        h.restore_auth_lifecycle_snapshot(&key, &before, before.expires_at)
1393            .unwrap();
1394
1395        let restored = h.snapshot(&key);
1396        assert_eq!(restored.phase, before.phase);
1397        assert_eq!(restored.expires_at, before.expires_at);
1398        assert_eq!(restored.credential_present, before.credential_present);
1399        assert_eq!(
1400            restored.credential_published_at_millis,
1401            before.credential_published_at_millis
1402        );
1403        assert_eq!(restored.generation, advanced.generation);
1404    }
1405
1406    #[test]
1407    fn restore_empty_zero_generation_snapshot_clears_runtime_authority() {
1408        let h = RuntimeAuthLeaseHandle::new();
1409        let key = lease("dev", "shared");
1410        h.acquire_lease(&key, 1_800_000_000).unwrap();
1411
1412        h.restore_auth_lifecycle_snapshot(
1413            &key,
1414            &AuthLeaseSnapshot {
1415                phase: None,
1416                expires_at: None,
1417                credential_present: false,
1418                generation: 0,
1419                credential_published_at_millis: None,
1420            },
1421            None,
1422        )
1423        .unwrap();
1424
1425        let restored = h.snapshot(&key);
1426        assert_eq!(restored.phase, None);
1427        assert_eq!(restored.expires_at, None);
1428        assert!(!restored.credential_present);
1429        assert_eq!(restored.generation, 0);
1430        assert_eq!(restored.credential_published_at_millis, None);
1431    }
1432
1433    #[test]
1434    fn per_binding_isolation() {
1435        let h = RuntimeAuthLeaseHandle::new();
1436        let openai = lease("dev", "openai");
1437        let anthropic = lease("dev", "anthropic");
1438        h.acquire_lease(&openai, 1_800_000_000).unwrap();
1439        h.acquire_lease(&anthropic, 1_900_000_000).unwrap();
1440        h.mark_expiring(&openai).unwrap();
1441
1442        assert_eq!(h.snapshot(&openai).phase, Some(AuthLeasePhase::Expiring));
1443        assert_eq!(h.snapshot(&anthropic).phase, Some(AuthLeasePhase::Valid));
1444        assert_eq!(h.snapshot(&anthropic).expires_at, Some(1_900_000_000));
1445    }
1446}