Skip to main content

openrtc/
native.rs

1//! Provider-neutral OpenRTC 2.0 control-plane client for pure native hosts.
2//!
3//! Consumer applications own authentication, attestation registration, and
4//! secure private-key storage. OpenRTC owns assertion exchange, device
5//! enrollment, avenue-scoped gateway grants, renewal, and replay-safe device
6//! proofs. No Firebase type or project identifier is exposed by this module.
7
8pub use crate::native_key_store::{load_or_create_endpoint_key, FileSigner};
9
10pub use crate::native_coordination_gateway::{
11    NativeAuthorityAssignment, NativeAuthorityInterestAssignment, NativeCapabilities,
12    NativeCapabilityHandle, NativeCapabilityKind, NativeGatewayGrant, NativeGatewayGrantProvider,
13    NativeGatewayGrantRequest, NativeServiceErrorObservation,
14};
15
16#[cfg(feature = "adaptive-room-sentinel")]
17#[doc(hidden)]
18#[derive(Debug, Clone, Copy, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct AdaptiveRoomSentinelOwnerLimits {
21    pub max_reconnect_attempts: u8,
22    pub max_operation_attempts: usize,
23    pub max_prepare_page_members: usize,
24    pub max_artifact_chunks: usize,
25}
26
27#[cfg(feature = "adaptive-room-sentinel")]
28#[doc(hidden)]
29pub fn adaptive_room_sentinel_owner_limits() -> AdaptiveRoomSentinelOwnerLimits {
30    AdaptiveRoomSentinelOwnerLimits {
31        max_reconnect_attempts: crate::native_coordination_gateway::MAX_RECONNECT_ATTEMPTS,
32        max_operation_attempts: crate::native_coordination_gateway::MAX_OPERATION_ATTEMPTS,
33        max_prepare_page_members: crate::managed_group_controller::MAX_PREPARE_PAGE_MEMBERS,
34        max_artifact_chunks: crate::managed_group_controller::MAX_ARTIFACT_CHUNKS,
35    }
36}
37
38/// Narrow Rust-native controller used by trusted host adapters such as the
39/// first-party Tauri plugin. The avenue remains the membership owner; this
40/// wrapper exposes only typed crypto actions and opaque sealed state.
41#[cfg(feature = "managed-group-encryption")]
42pub struct NativeManagedGroupController {
43    inner: crate::managed_group_controller::ManagedGroupController,
44}
45
46#[cfg(feature = "managed-group-encryption")]
47#[derive(Debug, Clone, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct NativeManagedProtectedPayload {
50    pub payload: String,
51    pub sealed_state: String,
52}
53
54#[cfg(feature = "managed-group-encryption")]
55impl NativeManagedGroupController {
56    pub fn new(
57        device_id: &str,
58        wrapping_key: [u8; 32],
59        sealed_state: Option<&[u8]>,
60    ) -> Result<Self> {
61        Ok(Self {
62            inner: crate::managed_group_controller::ManagedGroupController::new(
63                device_id,
64                wrapping_key,
65                sealed_state,
66            )?,
67        })
68    }
69
70    pub fn publish_key_package(&self) -> Result<Value> {
71        Ok(serde_json::to_value(self.inner.publish_key_package()?)?)
72    }
73
74    pub fn handle_prepare_page(&mut self, page: Value) -> Result<Vec<Value>> {
75        let page = serde_json::from_value(page)?;
76        self.inner
77            .handle_prepare_page(page)?
78            .into_iter()
79            .map(|action| serde_json::to_value(action).map_err(Into::into))
80            .collect()
81    }
82
83    pub fn handle_artifact_chunk(&mut self, chunk: Value) -> Result<Vec<Value>> {
84        let chunk = serde_json::from_value(chunk)?;
85        self.inner
86            .handle_artifact_chunk(chunk)?
87            .into_iter()
88            .map(|action| serde_json::to_value(action).map_err(Into::into))
89            .collect()
90    }
91
92    #[allow(clippy::too_many_arguments)]
93    pub fn seal_payload(
94        &mut self,
95        architecture_epoch: u64,
96        encryption_epoch: u64,
97        message_id: &str,
98        channel: &str,
99        priority: u8,
100        zone_id: Option<&str>,
101        payload: &[u8],
102    ) -> Result<NativeManagedProtectedPayload> {
103        let protected = self.inner.seal_payload(
104            architecture_epoch,
105            encryption_epoch,
106            message_id,
107            channel,
108            priority,
109            zone_id,
110            payload,
111        )?;
112        Ok(NativeManagedProtectedPayload {
113            payload: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(protected.data),
114            sealed_state: base64::engine::general_purpose::URL_SAFE_NO_PAD
115                .encode(protected.sealed_state),
116        })
117    }
118
119    #[allow(clippy::too_many_arguments)]
120    pub fn open_payload(
121        &mut self,
122        architecture_epoch: u64,
123        encryption_epoch: u64,
124        message_id: &str,
125        channel: &str,
126        priority: u8,
127        zone_id: Option<&str>,
128        ciphertext: &[u8],
129    ) -> Result<NativeManagedProtectedPayload> {
130        let protected = self.inner.open_payload(
131            architecture_epoch,
132            encryption_epoch,
133            message_id,
134            channel,
135            priority,
136            zone_id,
137            ciphertext,
138        )?;
139        Ok(NativeManagedProtectedPayload {
140            payload: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(protected.data),
141            sealed_state: base64::engine::general_purpose::URL_SAFE_NO_PAD
142                .encode(protected.sealed_state),
143        })
144    }
145}
146#[cfg(feature = "managed-group-encryption")]
147pub use crate::native_coordination_gateway::{
148    InMemoryNativeManagedGroupStateStore, NativeManagedGroupStateStore, NativeManagedRoomMessage,
149    NativeManagedRoomPublish,
150};
151use crate::signaling::{
152    Device, DeviceCapabilities, DeviceEvent, SessionEvent, SignalingBackend, SignalingSession,
153};
154use anyhow::{anyhow, bail, Context, Result};
155use async_trait::async_trait;
156use base64::Engine as _;
157use ed25519_dalek::{Signature, Verifier, VerifyingKey};
158use futures::stream::BoxStream;
159use serde::{Deserialize, Serialize};
160use serde_json::{json, Value};
161use std::fmt;
162use std::sync::{Arc, Mutex as StdMutex};
163use std::time::{SystemTime, UNIX_EPOCH};
164use tokio::sync::{watch, Mutex};
165use uuid::Uuid;
166
167pub const OPENRTC_PRODUCTION_CONTROL_PLANE: &str = "https://api.openrtc.app";
168const DEVICE_CERTIFICATE_RENEW_SKEW_MS: u64 = 24 * 60 * 60_000;
169const SOURCE_RENEW_SKEW_MS: u64 = 5 * 60_000;
170const REQUEST_TIMEOUT_SECONDS: u64 = 15;
171
172#[derive(Debug)]
173pub struct ControlPlaneHttpError {
174    pub(crate) status: u16,
175    pub(crate) message: String,
176    pub(crate) reason: Option<String>,
177    pub service_error: Option<crate::service_errors::ServiceError>,
178}
179
180impl ControlPlaneHttpError {
181    pub(crate) fn is_retryable(&self) -> bool {
182        if let Some(service) = &self.service_error {
183            return service.retryable;
184        }
185        self.status == 408 || self.status == 425 || self.status == 429 || self.status >= 500
186    }
187}
188
189impl fmt::Display for ControlPlaneHttpError {
190    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191        write!(
192            formatter,
193            "OpenRTC control-plane request failed ({}): {}",
194            self.status, self.message
195        )
196    }
197}
198
199impl std::error::Error for ControlPlaneHttpError {}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct IdentityAssertion {
203    pub token: String,
204    pub provider_id: Option<String>,
205}
206
207/// Application-owned source of short-lived OIDC/JWKS assertions.
208///
209/// Implementations may use Firebase Auth, Auth0, Clerk, a custom backend, or
210/// another registered issuer. The OpenRTC crate never initializes that IdP.
211#[async_trait]
212pub trait AssertionProvider: Send + Sync {
213    async fn assertion(&self, force_refresh: bool) -> Result<IdentityAssertion>;
214
215    /// Returns an assertion for the exact durable installation being enrolled.
216    ///
217    /// Consumer backends may enforce product device allowances while minting
218    /// the assertion, so the native API must expose the same device binding as
219    /// the browser/WASM `AuthProvider`. The default preserves source
220    /// compatibility for identity providers that do not perform per-device
221    /// product admission.
222    async fn assertion_for_device(
223        &self,
224        force_refresh: bool,
225        _device_id: &str,
226    ) -> Result<IdentityAssertion> {
227        self.assertion(force_refresh).await
228    }
229
230    /// Returns a newly authorized assertion after OpenRTC reports that this
231    /// installation ID is bound to a different public key. Consumer backends
232    /// should require recent interactive authentication before adding their
233    /// recovery authorization claim. This is never called for another 412.
234    async fn assertion_for_device_recovery(&self, device_id: &str) -> Result<IdentityAssertion> {
235        self.assertion_for_device(true, device_id).await
236    }
237
238    /// Stable, non-secret local identifier for one signed-in principal. It
239    /// changes on login/logout/account switch, never on access-token refresh.
240    /// OpenRTC hashes it with the app and install-key thumbprint before asking
241    /// the certificate store for a cold-start lookup. `None` preserves the RC
242    /// assertion-first compatibility behavior.
243    fn session_key(&self) -> Result<Option<String>> {
244        Ok(None)
245    }
246
247    /// Monotonic application-owned login epoch. Increment it only for login,
248    /// logout, account switch, or explicit revocation--never for an ordinary
249    /// OAuth access-token refresh.
250    fn identity_epoch(&self) -> u64 {
251        0
252    }
253
254    /// Optional immediate epoch observer. OpenRTC closes every capability
255    /// created under the old epoch when this value changes. Hosts without an
256    /// observer must explicitly close their handles during logout/account
257    /// switch; grant renewal still fails closed if `identity_epoch()` changed.
258    fn subscribe_identity_epoch(&self) -> Option<watch::Receiver<u64>> {
259        None
260    }
261}
262
263/// Host secure-storage bridge for the per-install Ed25519 device key.
264/// Private key bytes never enter the OpenRTC runtime.
265pub trait DeviceSigner: Send + Sync {
266    fn public_jwk(&self, app_tag: &str) -> Result<Value>;
267    fn sign(&self, app_tag: &str, challenge: &[u8]) -> Result<Vec<u8>>;
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct StoredCertificate {
273    pub app_tag: String,
274    pub principal_id: String,
275    pub device_id: String,
276    pub token: String,
277    pub expires_at_ms: u64,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub session_key_hash: Option<String>,
280    /// Public verification material returned alongside the certificate. Old
281    /// cache entries intentionally fail closed and re-enroll once.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub signing_public_jwk: Option<Value>,
284}
285
286/// Application-owned durable certificate storage. Desktop applications should
287/// protect this value with owner-only file permissions or the OS credential
288/// store. A certificate is public-key bound, but remains bearer authorization
289/// and therefore must not be placed in logs or world-readable preferences.
290#[async_trait]
291pub trait CertificateStore: Send + Sync {
292    async fn load(
293        &self,
294        app_tag: &str,
295        principal_id: &str,
296        device_id: &str,
297    ) -> Result<Option<StoredCertificate>>;
298
299    async fn save(&self, certificate: &StoredCertificate) -> Result<()>;
300
301    /// Optional cold-start lookup that avoids fetching an IdP assertion merely
302    /// to rediscover the stable OpenRTC principal. Production stores should
303    /// index the already-hashed value; raw consumer session identifiers must
304    /// never be persisted by OpenRTC.
305    async fn load_for_session(
306        &self,
307        _app_tag: &str,
308        _session_key_hash: &str,
309        _device_id: &str,
310    ) -> Result<Option<StoredCertificate>> {
311        Ok(None)
312    }
313
314    async fn remove(&self, app_tag: &str, principal_id: &str, device_id: &str) -> Result<()>;
315}
316
317/// Network-idle, process-local certificate cache for prototypes and tests.
318/// Production native applications should supply durable protected storage.
319#[derive(Default)]
320pub struct InMemoryCertificate {
321    certificate: StdMutex<Option<StoredCertificate>>,
322}
323
324#[async_trait]
325impl CertificateStore for InMemoryCertificate {
326    async fn load(
327        &self,
328        app_tag: &str,
329        principal_id: &str,
330        device_id: &str,
331    ) -> Result<Option<StoredCertificate>> {
332        Ok(self
333            .certificate
334            .lock()
335            .map_err(|_| anyhow!("native device certificate cache is poisoned"))?
336            .clone()
337            .filter(|value| {
338                value.app_tag == app_tag
339                    && value.principal_id == principal_id
340                    && value.device_id == device_id
341            }))
342    }
343
344    async fn save(&self, certificate: &StoredCertificate) -> Result<()> {
345        *self
346            .certificate
347            .lock()
348            .map_err(|_| anyhow!("native device certificate cache is poisoned"))? =
349            Some(certificate.clone());
350        Ok(())
351    }
352
353    async fn load_for_session(
354        &self,
355        app_tag: &str,
356        session_key_hash: &str,
357        device_id: &str,
358    ) -> Result<Option<StoredCertificate>> {
359        Ok(self
360            .certificate
361            .lock()
362            .map_err(|_| anyhow!("native device certificate cache is poisoned"))?
363            .clone()
364            .filter(|value| {
365                value.app_tag == app_tag
366                    && value.device_id == device_id
367                    && value.session_key_hash.as_deref() == Some(session_key_hash)
368            }))
369    }
370
371    async fn remove(&self, app_tag: &str, principal_id: &str, device_id: &str) -> Result<()> {
372        let mut guard = self
373            .certificate
374            .lock()
375            .map_err(|_| anyhow!("native device certificate cache is poisoned"))?;
376        if guard.as_ref().is_some_and(|value| {
377            value.app_tag == app_tag
378                && value.principal_id == principal_id
379                && value.device_id == device_id
380        }) {
381            *guard = None;
382        }
383        Ok(())
384    }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
388#[serde(rename_all = "camelCase")]
389pub struct AttestationEvidence {
390    pub kind: String,
391    pub token: String,
392}
393
394/// Optional application-owned managed-attestation bridge. It is invoked only
395/// during enrollment or certificate renewal, never as a socket heartbeat.
396#[async_trait]
397pub trait AttestationProvider: Send + Sync {
398    async fn evidence(&self, challenge: &str, api_key: &str) -> Result<AttestationEvidence>;
399}
400
401#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "camelCase")]
403pub struct Features {
404    pub iroh_relay: bool,
405    pub managed_turn: bool,
406    pub moq: bool,
407    pub ble: bool,
408    pub advanced_fanout: bool,
409    pub durable_membership: bool,
410}
411
412/// Public presentation metadata for one enrolled native installation.
413///
414/// This is deliberately provider-neutral and mirrors the browser/WASM v2
415/// device profile. It is persisted by the OpenRTC control plane so durable
416/// user-device discovery does not depend on a live presence frame to learn a
417/// useful name or platform.
418#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
419#[serde(rename_all = "camelCase")]
420pub struct DeviceProfile {
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub name: Option<String>,
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub platform: Option<String>,
425}
426
427#[derive(Clone, Default)]
428pub struct DeviceOptions {
429    pub max_peers: Option<u32>,
430    pub features: Features,
431    pub attestation: Option<Arc<dyn AttestationProvider>>,
432    pub identity_relay: Option<CredentialRelay>,
433    pub device_profile: Option<DeviceProfile>,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Eq)]
437pub struct CapabilityOptions {
438    pub max_peers: Option<u32>,
439    pub features: Features,
440}
441
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "lowercase")]
444pub enum RoomArchitectureMode {
445    #[default]
446    Auto,
447    Mesh,
448    Sparse,
449    Managed,
450    Authority,
451}
452
453#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "kebab-case")]
455pub enum RoomDelivery {
456    #[default]
457    Reliable,
458    LatestState,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "lowercase")]
463pub enum EffectiveRoomArchitecture {
464    Mesh,
465    Sparse,
466    Managed,
467    Authority,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "lowercase")]
472pub enum RoomArchitecturePhase {
473    Preparing,
474    Settled,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "lowercase")]
479pub enum RoomArchitectureReason {
480    Size,
481    Traffic,
482    Latency,
483    Cost,
484    Capacity,
485    Manual,
486    Recovery,
487}
488
489/// Customer-safe room policy projection delivered by the avenue gateway.
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491#[serde(rename_all = "camelCase")]
492pub struct RoomArchitectureSnapshot {
493    pub requested: RoomArchitectureMode,
494    pub effective: EffectiveRoomArchitecture,
495    pub epoch: u64,
496    pub phase: RoomArchitecturePhase,
497    pub reason: RoomArchitectureReason,
498    pub held_credits_usd: f64,
499    pub quote_expires_at_ms: u64,
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq)]
503pub struct RoomOptions {
504    pub max_peers: Option<u32>,
505    pub features: Features,
506    pub architecture: RoomArchitectureMode,
507    pub delivery: RoomDelivery,
508}
509
510/// Developer-backend-only configuration for one authoritative room replica.
511/// The service grant is bound to this replica's device signer; the separate
512/// assignment signer can be shared by equivalent replicas after an
513/// application-owned state handoff.
514pub struct RoomAuthorityOptions {
515    pub service_id: String,
516    pub generation: u64,
517    pub shard_ids: Vec<String>,
518    /// Total room members including services, within 1..=50. None uses app policy.
519    pub max_peers: Option<u32>,
520    pub features: Features,
521    pub assignment_signer: Arc<dyn DeviceSigner>,
522}
523
524/// One connected developer authority capability. It uses the same native
525/// gateway and Rust peer-session owner as every other room; it adds only the
526/// signed AOI assignment permission carried by its server-minted grant.
527pub struct RoomAuthority {
528    handle: NativeCapabilityHandle,
529    app_tag: String,
530    service_id: String,
531    generation: u64,
532    shard_ids: Vec<String>,
533    assignment_signer: Arc<dyn DeviceSigner>,
534}
535
536impl RoomAuthority {
537    pub fn subscribe_service_errors(
538        &self,
539    ) -> Result<tokio::sync::broadcast::Receiver<NativeServiceErrorObservation>> {
540        self.handle.subscribe_service_errors()
541    }
542
543    /// Compose the dedicated native runtime for this service room.
544    ///
545    /// The authenticated gateway supplies the permitted edges to the existing
546    /// Rust peer-session actor. This does not initialize an endpoint, publish
547    /// presence, or require JavaScript. One authority handle composes one client.
548    pub async fn compose_client(
549        &self,
550        builder: crate::client::ClientBuilder,
551    ) -> Result<Arc<crate::Client>> {
552        self.handle.compose_authority_client(builder).await
553    }
554
555    pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
556        self.handle.signaling()
557    }
558
559    pub fn room_architecture(&self) -> Result<Option<RoomArchitectureSnapshot>> {
560        self.handle.room_architecture()
561    }
562
563    pub async fn publish_assignment(
564        &self,
565        revision: u64,
566        subject_device_id: impl Into<String>,
567        shard_id: impl Into<String>,
568        priority_device_ids: Vec<String>,
569        relevant_entity_ids: Vec<String>,
570        ttl_ms: u64,
571    ) -> Result<NativeAuthorityInterestAssignment> {
572        if revision == 0 || ttl_ms == 0 || ttl_ms > 2 * 60_000 {
573            bail!("authority assignment revision or TTL is invalid");
574        }
575        let subject_device_id = bounded_id("subject_device_id", subject_device_id.into())?;
576        let shard_id = bounded_id("shard_id", shard_id.into())?;
577        if !self.shard_ids.contains(&shard_id) {
578            bail!("authority assignment shard is not owned by this service replica");
579        }
580        crate::native_coordination_gateway::validate_native_authority_assignment_targets(
581            &priority_device_ids,
582            &relevant_entity_ids,
583        )?;
584        let issued_at_ms = now_ms();
585        let assignment = NativeAuthorityInterestAssignment {
586            policy_version: "room-authority-assignment-v1".to_string(),
587            service_id: self.service_id.clone(),
588            generation: self.generation,
589            revision,
590            subject_device_id,
591            shard_id,
592            priority_device_ids,
593            relevant_entity_ids,
594            issued_at_ms,
595            expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
596        };
597        let payload =
598            serde_json::to_vec(&assignment).context("encode native authority assignment")?;
599        let signature = self.assignment_signer.sign(&self.app_tag, &payload)?;
600        if signature.len() != 64 {
601            bail!("native authority assignment signer returned an invalid Ed25519 signature");
602        }
603        self.handle
604            .publish_authority_assignment(
605                assignment.clone(),
606                base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature),
607            )
608            .await?;
609        Ok(assignment)
610    }
611
612    pub async fn close(&self) {
613        self.handle.close().await;
614    }
615}
616
617impl From<CapabilityOptions> for RoomOptions {
618    fn from(value: CapabilityOptions) -> Self {
619        Self {
620            max_peers: value.max_peers,
621            features: value.features,
622            architecture: RoomArchitectureMode::Auto,
623            delivery: RoomDelivery::Reliable,
624        }
625    }
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629enum AnonymousKind {
630    Space,
631    Room,
632    Ticket,
633}
634
635impl AnonymousKind {
636    fn source_kind(self) -> &'static str {
637        match self {
638            Self::Space => "space",
639            Self::Room => "room",
640            Self::Ticket => "ticket",
641        }
642    }
643}
644
645/// Mutable opaque-credential relay for native runtimes constructed before the
646/// application finishes login. It carries only OpenRTC-issued device
647/// certificates and follows certificate renewal performed by the grant owner.
648#[derive(Clone, Default)]
649pub struct CredentialRelay {
650    value: Arc<StdMutex<Option<String>>>,
651}
652
653impl CredentialRelay {
654    pub fn provider(&self) -> Box<dyn Fn() -> Option<String> + Send + Sync> {
655        let state = self.value.clone();
656        Box::new(move || state.lock().ok().and_then(|value| value.clone()))
657    }
658
659    fn set(&self, value: Option<String>) -> Result<()> {
660        *self
661            .value
662            .lock()
663            .map_err(|_| anyhow!("native identity credential state is poisoned"))? = value;
664        Ok(())
665    }
666}
667
668/// One-time, fail-closed signaling installation point for native hosts whose
669/// process is created before user authentication completes. Installation may
670/// happen exactly once; replacement requires closing and rebuilding the
671/// capability/runtime so this cannot become a shadow lifecycle owner.
672#[derive(Default)]
673pub struct SignalingSlot {
674    backend: tokio::sync::RwLock<Option<Arc<dyn SignalingBackend>>>,
675}
676
677impl SignalingSlot {
678    pub async fn install(&self, backend: Arc<dyn SignalingBackend>) -> Result<()> {
679        let mut current = self.backend.write().await;
680        if current.is_some() {
681            bail!("native OpenRTC capability signaling is already installed");
682        }
683        *current = Some(backend);
684        Ok(())
685    }
686
687    async fn current(&self) -> Result<Arc<dyn SignalingBackend>> {
688        self.backend
689            .read()
690            .await
691            .clone()
692            .ok_or_else(|| anyhow!("native OpenRTC capability is not active"))
693    }
694}
695
696#[async_trait]
697impl SignalingBackend for SignalingSlot {
698    async fn update_presence(
699        &self,
700        user_id: &str,
701        local_node_id: &str,
702        ticket_str: &str,
703        is_online: bool,
704        name: &str,
705        ttl_ms: u64,
706        metadata: Option<&str>,
707    ) -> Result<()> {
708        self.current()
709            .await?
710            .update_presence(
711                user_id,
712                local_node_id,
713                ticket_str,
714                is_online,
715                name,
716                ttl_ms,
717                metadata,
718            )
719            .await
720    }
721
722    async fn set_offline(&self, user_id: &str, local_node_id: &str) -> Result<()> {
723        self.current()
724            .await?
725            .set_offline(user_id, local_node_id)
726            .await
727    }
728
729    async fn update_live_presence(
730        &self,
731        user_id: &str,
732        local_node_id: &str,
733        ticket_str: &str,
734        name: &str,
735        metadata: Option<&str>,
736    ) -> Result<()> {
737        self.current()
738            .await?
739            .update_live_presence(user_id, local_node_id, ticket_str, name, metadata)
740            .await
741    }
742
743    async fn set_live_presence_offline(&self, user_id: &str, local_node_id: &str) -> Result<()> {
744        self.current()
745            .await?
746            .set_live_presence_offline(user_id, local_node_id)
747            .await
748    }
749
750    async fn update_device(
751        &self,
752        user_id: &str,
753        device_id: &str,
754        device_name: Option<&str>,
755        capabilities: Option<DeviceCapabilities>,
756        metadata: Option<&str>,
757    ) -> Result<()> {
758        self.current()
759            .await?
760            .update_device(user_id, device_id, device_name, capabilities, metadata)
761            .await
762    }
763
764    async fn delete_device(&self, user_id: &str, device_id: &str) -> Result<()> {
765        self.current()
766            .await?
767            .delete_device(user_id, device_id)
768            .await
769    }
770
771    async fn set_excluded_peers(
772        &self,
773        user_id: &str,
774        local_node_id: &str,
775        excluded_peers: &[String],
776    ) -> Result<()> {
777        self.current()
778            .await?
779            .set_excluded_peers(user_id, local_node_id, excluded_peers)
780            .await
781    }
782
783    async fn search_devices(
784        &self,
785        user_id: &str,
786        exclude_node_id: Option<&str>,
787    ) -> Result<Vec<Device>> {
788        self.current()
789            .await?
790            .search_devices(user_id, exclude_node_id)
791            .await
792    }
793
794    async fn list_devices(
795        &self,
796        user_id: &str,
797        exclude_node_id: Option<&str>,
798    ) -> Result<Vec<Device>> {
799        self.current()
800            .await?
801            .list_devices(user_id, exclude_node_id)
802            .await
803    }
804
805    async fn send_message(
806        &self,
807        sender_id: &str,
808        target_id: &str,
809        payload: &str,
810        state: Option<&str>,
811        reply_payload: Option<&str>,
812    ) -> Result<String> {
813        self.current()
814            .await?
815            .send_message(sender_id, target_id, payload, state, reply_payload)
816            .await
817    }
818
819    async fn subscribe_devices(
820        &self,
821        user_id: &str,
822    ) -> Result<BoxStream<'static, Result<Vec<DeviceEvent>>>> {
823        self.current().await?.subscribe_devices(user_id).await
824    }
825
826    async fn create_session(&self, session: SignalingSession) -> Result<()> {
827        self.current().await?.create_session(session).await
828    }
829
830    async fn update_session(&self, session_id: &str, update_data: Value) -> Result<()> {
831        self.current()
832            .await?
833            .update_session(session_id, update_data)
834            .await
835    }
836
837    async fn subscribe_sessions(
838        &self,
839        local_device_id: &str,
840    ) -> Result<BoxStream<'static, Result<Vec<SessionEvent>>>> {
841        self.current()
842            .await?
843            .subscribe_sessions(local_device_id)
844            .await
845    }
846}
847
848#[derive(Debug, Clone)]
849struct CredentialSource {
850    token: String,
851    expires_at_ms: u64,
852    principal_id: String,
853    relay: RelayAvailability,
854}
855
856#[derive(Debug, Clone)]
857struct AnonymousCredentialSource {
858    token: String,
859    expires_at_ms: u64,
860    principal_id: String,
861    device_id: String,
862    requested_id: String,
863    avenue_id: String,
864    relay: RelayAvailability,
865}
866
867#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(rename_all = "camelCase")]
869pub struct RelayAvailability {
870    pub iroh_relay: bool,
871    pub managed_turn: bool,
872}
873
874fn relay_availability(
875    iroh_relay: Option<bool>,
876    managed_turn: bool,
877    legacy_relay: Option<bool>,
878) -> RelayAvailability {
879    RelayAvailability {
880        iroh_relay: iroh_relay.or(legacy_relay).unwrap_or(true),
881        // The legacy relay claim never proves that managed TURN is deployed.
882        managed_turn,
883    }
884}
885
886fn relay_availability_from_token(token: &str) -> Result<RelayAvailability> {
887    let claims = decode_claims(token)?;
888    Ok(relay_availability(
889        claims.get("irohRelay").and_then(Value::as_bool),
890        claims
891            .get("managedTurn")
892            .and_then(Value::as_bool)
893            .unwrap_or(false),
894        claims.get("relay").and_then(Value::as_bool),
895    ))
896}
897
898fn available_features(mut requested: Features, relay: RelayAvailability) -> Features {
899    requested.iroh_relay &= relay.iroh_relay;
900    requested.managed_turn &= relay.managed_turn;
901    requested
902}
903
904#[derive(Clone)]
905pub struct ControlPlane {
906    inner: Arc<ControlPlaneInner>,
907}
908
909struct ControlPlaneInner {
910    api_key: String,
911    app_tag: String,
912    endpoint: String,
913    gateway_endpoint: String,
914    http: reqwest::Client,
915    assertion_provider: Option<Arc<dyn AssertionProvider>>,
916    signer: Arc<dyn DeviceSigner>,
917    certificate_store: Arc<dyn CertificateStore>,
918    #[cfg(feature = "managed-group-encryption")]
919    managed_group_store:
920        StdMutex<Arc<dyn crate::native_coordination_gateway::NativeManagedGroupStateStore>>,
921    auth_epoch: Mutex<AuthEpochState>,
922}
923
924#[derive(Debug, Clone)]
925struct CachedIdentity {
926    response: AssertionExchangeResponse,
927    expires_at_ms: u64,
928    device_id: String,
929}
930
931#[derive(Debug, Default)]
932struct AuthEpochState {
933    epoch: Option<u64>,
934    identity: Option<CachedIdentity>,
935}
936
937/// An authenticated device-mesh capability plus its stable app-scoped
938/// principal. The handle remains network-idle until the runtime publishes its
939/// initial presence, and `close()` stops its socket and grant-refresh owner.
940pub struct Devices {
941    pub principal_id: String,
942    pub handle: NativeCapabilityHandle,
943    identity_relay: CredentialRelay,
944    identity_epoch_monitor: Option<tokio::task::JoinHandle<()>>,
945    relay: RelayAvailability,
946}
947
948/// A backend-free native capability. Its install-bound principal and device
949/// identity come from the host-secure Ed25519 key, and the handle owns exactly
950/// one live avenue.
951pub struct AnonymousCapability {
952    pub principal_id: String,
953    pub handle: NativeCapabilityHandle,
954    relay: RelayAvailability,
955}
956
957impl Devices {
958    pub fn relay_availability(&self) -> RelayAvailability {
959        self.relay
960    }
961    /// Opaque device certificate used by Rust peer-session admission. The
962    /// provider observes certificate renewal without exposing any private key.
963    pub fn identity_credential_provider(&self) -> Box<dyn Fn() -> Option<String> + Send + Sync> {
964        self.identity_relay.provider()
965    }
966
967    /// Install this one avenue in the shared Rust runtime. The returned
968    /// backend remains owned by this disposable capability handle.
969    pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
970        self.handle.signaling()
971    }
972
973    /// Return media already bound to this devices avenue. The capability key
974    /// remains internal while native and browser peers share the same scoped
975    /// stream envelope.
976    pub fn media_connection(
977        &self,
978        client: Arc<crate::Client>,
979        peer_id: impl Into<String>,
980    ) -> Result<crate::media::MediaConnection> {
981        media_connection_for_capability(&self.handle, client, peer_id)
982    }
983
984    pub fn is_closed(&self) -> bool {
985        self.handle.is_closed()
986    }
987
988    pub async fn close(&self) {
989        if let Some(monitor) = &self.identity_epoch_monitor {
990            monitor.abort();
991        }
992        self.handle.close().await;
993    }
994}
995
996impl Drop for Devices {
997    fn drop(&mut self) {
998        if let Some(monitor) = &self.identity_epoch_monitor {
999            monitor.abort();
1000        }
1001    }
1002}
1003
1004impl AnonymousCapability {
1005    /// Compose the Rust peer-session owner for an authority-room consumer.
1006    ///
1007    /// Join with `RoomArchitectureMode::Authority` first. No service secret or
1008    /// JavaScript runtime is needed: the gateway supplies assigned routes and
1009    /// receiver permissions to the same owner used by `RoomAuthority`.
1010    /// Other avenue architectures are not supported by this composition helper.
1011    pub async fn compose_client(
1012        &self,
1013        builder: crate::client::ClientBuilder,
1014    ) -> Result<Arc<crate::Client>> {
1015        self.handle.compose_authority_client(builder).await
1016    }
1017
1018    pub fn relay_availability(&self) -> RelayAvailability {
1019        self.relay
1020    }
1021    /// Install this one avenue in the shared Rust runtime. Capability/grant
1022    /// renewal remains internal and does not replace a healthy socket.
1023    pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
1024        self.handle.signaling()
1025    }
1026
1027    pub fn room_architecture(&self) -> Result<Option<RoomArchitectureSnapshot>> {
1028        self.handle.room_architecture()
1029    }
1030
1031    pub fn subscribe_room_architecture(
1032        &self,
1033    ) -> Result<watch::Receiver<Option<RoomArchitectureSnapshot>>> {
1034        self.handle.subscribe_room_architecture()
1035    }
1036
1037    /// Return media already bound to this space, room, or ticket capability.
1038    pub fn media_connection(
1039        &self,
1040        client: Arc<crate::Client>,
1041        peer_id: impl Into<String>,
1042    ) -> Result<crate::media::MediaConnection> {
1043        media_connection_for_capability(&self.handle, client, peer_id)
1044    }
1045
1046    pub fn is_closed(&self) -> bool {
1047        self.handle.is_closed()
1048    }
1049
1050    pub async fn close(&self) {
1051        self.handle.close().await;
1052    }
1053}
1054
1055fn media_connection_for_capability(
1056    handle: &NativeCapabilityHandle,
1057    client: Arc<crate::Client>,
1058    peer_id: impl Into<String>,
1059) -> Result<crate::media::MediaConnection> {
1060    let kind = match handle.kind() {
1061        NativeCapabilityKind::Devices => "devices",
1062        NativeCapabilityKind::Space => "space",
1063        NativeCapabilityKind::Room => "room",
1064        NativeCapabilityKind::Ticket => "ticket",
1065    };
1066    crate::media::MediaConnection::for_capability(client, peer_id, kind, handle.id())
1067}
1068
1069impl ControlPlane {
1070    pub fn new(
1071        api_key: &str,
1072        assertion_provider: Arc<dyn AssertionProvider>,
1073        signer: Arc<dyn DeviceSigner>,
1074        certificate_store: Arc<dyn CertificateStore>,
1075    ) -> Result<Self> {
1076        let api_key = crate::validate_api_key(api_key)?.to_string();
1077        let app_tag = crate::app_tag_from_api_key(&api_key);
1078        Ok(Self {
1079            inner: Arc::new(ControlPlaneInner {
1080                api_key,
1081                app_tag,
1082                endpoint: OPENRTC_PRODUCTION_CONTROL_PLANE.to_string(),
1083                gateway_endpoint:
1084                    crate::native_coordination_gateway::OPENRTC_PRODUCTION_COORDINATION_GATEWAY
1085                        .to_string(),
1086                http: reqwest::Client::builder()
1087                    .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
1088                    .build()?,
1089                assertion_provider: Some(assertion_provider),
1090                signer,
1091                certificate_store,
1092                #[cfg(feature = "managed-group-encryption")]
1093                managed_group_store: StdMutex::new(Arc::new(
1094                    crate::native_coordination_gateway::InMemoryNativeManagedGroupStateStore::default(),
1095                )),
1096                auth_epoch: Mutex::new(AuthEpochState::default()),
1097            }),
1098        })
1099    }
1100
1101    /// Construct the API-key-only native prototype surface. Construction is
1102    /// network-idle. A capability request occurs only when `join_space`,
1103    /// `join_room`, or `issue_ticket` is awaited.
1104    pub fn anonymous(api_key: &str, signer: Arc<dyn DeviceSigner>) -> Result<Self> {
1105        let api_key = crate::validate_api_key(api_key)?.to_string();
1106        let app_tag = crate::app_tag_from_api_key(&api_key);
1107        Ok(Self {
1108            inner: Arc::new(ControlPlaneInner {
1109                api_key,
1110                app_tag,
1111                endpoint: OPENRTC_PRODUCTION_CONTROL_PLANE.to_string(),
1112                gateway_endpoint:
1113                    crate::native_coordination_gateway::OPENRTC_PRODUCTION_COORDINATION_GATEWAY
1114                        .to_string(),
1115                http: reqwest::Client::builder()
1116                    .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
1117                    .build()?,
1118                assertion_provider: None,
1119                signer,
1120                certificate_store: Arc::new(InMemoryCertificate::default()),
1121                #[cfg(feature = "managed-group-encryption")]
1122                managed_group_store: StdMutex::new(Arc::new(
1123                    crate::native_coordination_gateway::InMemoryNativeManagedGroupStateStore::default(),
1124                )),
1125                auth_epoch: Mutex::new(AuthEpochState::default()),
1126            }),
1127        })
1128    }
1129
1130    pub fn app_tag(&self) -> &str {
1131        &self.inner.app_tag
1132    }
1133
1134    /// Replace the process-local prototype store with durable owner-only
1135    /// app-private storage before joining a managed room.
1136    #[cfg(feature = "managed-group-encryption")]
1137    pub fn set_managed_group_state_store(
1138        &self,
1139        store: Arc<dyn crate::native_coordination_gateway::NativeManagedGroupStateStore>,
1140    ) -> Result<()> {
1141        *self
1142            .inner
1143            .managed_group_store
1144            .lock()
1145            .map_err(|_| anyhow!("native managed group state store lock is poisoned"))? = store;
1146        Ok(())
1147    }
1148
1149    pub fn with_testing_endpoints(
1150        self,
1151        control_plane: impl Into<String>,
1152        gateway: impl Into<String>,
1153    ) -> Result<Self> {
1154        #[cfg(any(test, feature = "testing-endpoints"))]
1155        {
1156            let mut control = self;
1157            let control_plane = validate_endpoint(control_plane.into())?;
1158            let gateway = validate_endpoint(gateway.into())?;
1159            Arc::get_mut(&mut control.inner)
1160                .ok_or_else(|| anyhow!("testing endpoints must be set before cloning the client"))?
1161                .endpoint = control_plane;
1162            Arc::get_mut(&mut control.inner)
1163                .ok_or_else(|| anyhow!("testing endpoints must be set before cloning the client"))?
1164                .gateway_endpoint = gateway;
1165            Ok(control)
1166        }
1167        #[cfg(not(any(test, feature = "testing-endpoints")))]
1168        {
1169            let _ = (control_plane.into(), gateway.into());
1170            Err(anyhow!(
1171                "testing endpoints require the OpenRTC testing-endpoints feature"
1172            ))
1173        }
1174    }
1175
1176    pub async fn devices(
1177        &self,
1178        device_id: impl Into<String>,
1179        platform_type: impl Into<String>,
1180        options: DeviceOptions,
1181    ) -> Result<Devices> {
1182        let device_id = bounded_id("device_id", device_id.into())?;
1183        let platform_type = bounded_id("platform_type", platform_type.into())?;
1184        let device_profile = normalized_device_profile(options.device_profile, &platform_type)?;
1185        let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1186            anyhow!("authenticated devices require a native identity assertion provider")
1187        })?;
1188        let mut epoch_receiver = assertion_provider.subscribe_identity_epoch();
1189        let identity_epoch = epoch_receiver
1190            .as_ref()
1191            .map(|receiver| *receiver.borrow())
1192            .unwrap_or_else(|| assertion_provider.identity_epoch());
1193        let source = self
1194            .device_certificate(
1195                &device_id,
1196                device_profile.as_ref(),
1197                options.attestation.as_deref(),
1198                identity_epoch,
1199            )
1200            .await?;
1201        let principal_id = source.principal_id.clone();
1202        let relay = source.relay;
1203        let identity_relay = options.identity_relay.clone().unwrap_or_default();
1204        identity_relay.set(Some(source.token.clone()))?;
1205        let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1206            Arc::new(ControlPlaneGrantProvider {
1207                control_plane: self.clone(),
1208                device_id: device_id.clone(),
1209                source: Mutex::new(source),
1210                max_peers: options.max_peers,
1211                features: options.features,
1212                device_profile,
1213                attestation: options.attestation,
1214                identity_relay: identity_relay.clone(),
1215                identity_epoch,
1216            });
1217        let capabilities = NativeCapabilities::new(
1218            &self.inner.api_key,
1219            device_id,
1220            platform_type,
1221            grant_provider,
1222        )?;
1223        #[cfg(feature = "managed-group-encryption")]
1224        let capabilities = capabilities.with_managed_group_storage(
1225            self.inner.signer.clone(),
1226            self.inner
1227                .managed_group_store
1228                .lock()
1229                .map_err(|_| anyhow!("native managed group state store lock is poisoned"))?
1230                .clone(),
1231        );
1232        #[cfg(any(test, feature = "testing-endpoints"))]
1233        let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1234        let handle = capabilities.devices(principal_id.clone())?;
1235        let identity_epoch_monitor = epoch_receiver.take().map(|receiver| {
1236            spawn_identity_epoch_monitor(
1237                receiver,
1238                identity_epoch,
1239                handle.closer(),
1240                identity_relay.clone(),
1241            )
1242        });
1243        Ok(Devices {
1244            handle,
1245            principal_id,
1246            identity_relay,
1247            identity_epoch_monitor,
1248            relay,
1249        })
1250    }
1251
1252    pub async fn join_space(
1253        &self,
1254        id: impl Into<String>,
1255        platform_type: impl Into<String>,
1256        options: CapabilityOptions,
1257    ) -> Result<AnonymousCapability> {
1258        self.anonymous_capability(
1259            AnonymousKind::Space,
1260            id.into(),
1261            platform_type.into(),
1262            options,
1263            None,
1264            None,
1265        )
1266        .await
1267    }
1268
1269    pub async fn join_room(
1270        &self,
1271        id: impl Into<String>,
1272        platform_type: impl Into<String>,
1273        options: impl Into<RoomOptions>,
1274    ) -> Result<AnonymousCapability> {
1275        let options = options.into();
1276        self.anonymous_capability(
1277            AnonymousKind::Room,
1278            id.into(),
1279            platform_type.into(),
1280            CapabilityOptions {
1281                max_peers: options.max_peers,
1282                features: options.features,
1283            },
1284            Some(options.architecture),
1285            Some(options.delivery),
1286        )
1287        .await
1288    }
1289
1290    /// Join a room as a developer-hosted authoritative service replica.
1291    ///
1292    /// `secret_key` is sent only to the developer-server control-plane route;
1293    /// it is never included in a gateway frame or exposed through TypeScript.
1294    pub async fn join_authority_room(
1295        &self,
1296        secret_key: impl Into<String>,
1297        room_id: impl Into<String>,
1298        device_id: impl Into<String>,
1299        platform_type: impl Into<String>,
1300        options: RoomAuthorityOptions,
1301    ) -> Result<RoomAuthority> {
1302        if options
1303            .max_peers
1304            .is_some_and(|count| !(1..=50).contains(&count))
1305        {
1306            bail!("authority service maxPeers must be within 1..50 room members");
1307        }
1308        let secret_key = required("secret_key", secret_key.into())?;
1309        if !secret_key.starts_with("sk_") || secret_key.len() > 128 {
1310            bail!("authority service secret key is invalid");
1311        }
1312        let room_id = bounded_id("room_id", room_id.into())?;
1313        let device_id = bounded_id("device_id", device_id.into())?;
1314        let platform_type = bounded_id("platform_type", platform_type.into())?;
1315        let service_id = bounded_id("service_id", options.service_id)?;
1316        if service_id.len() > 80 || options.generation == 0 {
1317            bail!("authority service identity or generation is invalid");
1318        }
1319        if options.shard_ids.is_empty() || options.shard_ids.len() > 64 {
1320            bail!("authority service must own from 1 to 64 shards");
1321        }
1322        let mut shard_ids = options
1323            .shard_ids
1324            .into_iter()
1325            .map(|value| bounded_id("shard_id", value))
1326            .collect::<Result<Vec<_>>>()?;
1327        let shard_count = shard_ids.len();
1328        if shard_ids.iter().any(|value| value.len() > 80) {
1329            bail!("authority service shard identifier is invalid");
1330        }
1331        shard_ids.sort();
1332        shard_ids.dedup();
1333        if shard_ids.len() != shard_count {
1334            bail!("authority service shard identifiers must be unique");
1335        }
1336        let assignment_public_jwk = options.assignment_signer.public_jwk(&self.inner.app_tag)?;
1337        validate_public_jwk(&assignment_public_jwk)?;
1338        let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1339            Arc::new(AuthorityServiceGrantProvider {
1340                control_plane: self.clone(),
1341                secret_key,
1342                service_id: service_id.clone(),
1343                generation: options.generation,
1344                shard_ids: shard_ids.clone(),
1345                assignment_public_jwk,
1346                device_id: device_id.clone(),
1347                max_peers: options.max_peers,
1348                features: options.features,
1349            });
1350        let capabilities = NativeCapabilities::new(
1351            &self.inner.api_key,
1352            device_id,
1353            platform_type,
1354            grant_provider,
1355        )?;
1356        #[cfg(any(test, feature = "testing-endpoints"))]
1357        let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1358        let handle = capabilities.join_room_with_options(
1359            room_id,
1360            RoomArchitectureMode::Authority,
1361            RoomDelivery::Reliable,
1362        )?;
1363        Ok(RoomAuthority {
1364            handle,
1365            app_tag: self.inner.app_tag.clone(),
1366            service_id,
1367            generation: options.generation,
1368            shard_ids,
1369            assignment_signer: options.assignment_signer,
1370        })
1371    }
1372
1373    pub async fn issue_ticket(
1374        &self,
1375        id: impl Into<String>,
1376        platform_type: impl Into<String>,
1377        options: CapabilityOptions,
1378    ) -> Result<AnonymousCapability> {
1379        self.anonymous_capability(
1380            AnonymousKind::Ticket,
1381            id.into(),
1382            platform_type.into(),
1383            options,
1384            None,
1385            None,
1386        )
1387        .await
1388    }
1389
1390    async fn anonymous_capability(
1391        &self,
1392        kind: AnonymousKind,
1393        id: String,
1394        platform_type: String,
1395        options: CapabilityOptions,
1396        architecture: Option<RoomArchitectureMode>,
1397        room_delivery: Option<RoomDelivery>,
1398    ) -> Result<AnonymousCapability> {
1399        let platform_type = bounded_id("platform_type", platform_type)?;
1400        let requested_id = bounded_id("capability_id", id)?;
1401        let source = self
1402            .anonymous_source(
1403                kind,
1404                requested_id,
1405                options.max_peers,
1406                architecture,
1407                room_delivery,
1408            )
1409            .await?;
1410        let principal_id = source.principal_id.clone();
1411        let device_id = source.device_id.clone();
1412        let avenue_id = source.avenue_id.clone();
1413        let relay = source.relay;
1414        let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1415            Arc::new(AnonymousControlPlaneGrantProvider {
1416                control_plane: self.clone(),
1417                source: Mutex::new(source),
1418                kind,
1419                max_peers: options.max_peers,
1420                features: options.features,
1421                architecture,
1422                room_delivery,
1423            });
1424        let capabilities = NativeCapabilities::new(
1425            &self.inner.api_key,
1426            device_id,
1427            platform_type,
1428            grant_provider,
1429        )?;
1430        #[cfg(feature = "managed-group-encryption")]
1431        let capabilities = capabilities.with_managed_group_storage(
1432            self.inner.signer.clone(),
1433            self.inner
1434                .managed_group_store
1435                .lock()
1436                .map_err(|_| anyhow!("native managed group state store lock is poisoned"))?
1437                .clone(),
1438        );
1439        #[cfg(any(test, feature = "testing-endpoints"))]
1440        let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1441        let handle = match kind {
1442            AnonymousKind::Space => capabilities.join_space(avenue_id)?,
1443            AnonymousKind::Room => capabilities.join_room_with_options(
1444                avenue_id,
1445                architecture.unwrap_or_default(),
1446                room_delivery.unwrap_or_default(),
1447            )?,
1448            AnonymousKind::Ticket => capabilities.issue_ticket(avenue_id)?,
1449        };
1450        Ok(AnonymousCapability {
1451            principal_id,
1452            handle,
1453            relay,
1454        })
1455    }
1456
1457    async fn anonymous_source(
1458        &self,
1459        kind: AnonymousKind,
1460        requested_id: String,
1461        max_peers: Option<u32>,
1462        architecture: Option<RoomArchitectureMode>,
1463        room_delivery: Option<RoomDelivery>,
1464    ) -> Result<AnonymousCredentialSource> {
1465        let original_requested_id = requested_id.clone();
1466        let avenue_id = native_capability_avenue_id(&self.inner.api_key, kind, &requested_id);
1467        let proof = self.device_proof(|nonce, issued_at| {
1468            format!(
1469                "openrtc:v2:capability:{}:{}:{}:{}:{}",
1470                self.inner.api_key,
1471                kind.source_kind(),
1472                avenue_id,
1473                nonce,
1474                issued_at
1475            )
1476        })?;
1477        let mut body = json!({
1478            "avenue": { "kind": kind.source_kind(), "id": avenue_id },
1479            "deviceProof": proof,
1480        });
1481        if let Some(max_peers) = max_peers {
1482            body["maxPeers"] = json!(max_peers);
1483        }
1484        if let Some(architecture) = architecture {
1485            body["architecture"] = json!(architecture);
1486        }
1487        if let Some(room_delivery) = room_delivery {
1488            body["roomDelivery"] = json!(room_delivery);
1489        }
1490        let response: CapabilityResponse = self.post("/v2/capabilities", body).await?;
1491        let token = required("capability", response.capability)?;
1492        let principal_id = required_claim_string(&token, "principalId")?;
1493        let thumbprint = required_claim_string(&token, "deviceKeyThumbprint")?;
1494        let claim_exp = decode_claims(&token)?
1495            .get("exp")
1496            .and_then(Value::as_u64)
1497            .ok_or_else(|| anyhow!("OpenRTC capability is missing exp"))?;
1498        if claim_exp != response.expires_at
1499            || response.avenue.kind != kind.source_kind()
1500            || response.avenue.id != avenue_id
1501        {
1502            bail!("OpenRTC capability response scope is inconsistent");
1503        }
1504        Ok(AnonymousCredentialSource {
1505            token,
1506            expires_at_ms: response.expires_at.saturating_mul(1_000),
1507            principal_id,
1508            device_id: format!("anon_{}", thumbprint.chars().take(32).collect::<String>()),
1509            requested_id: original_requested_id,
1510            avenue_id,
1511            relay: relay_availability(response.iroh_relay, response.managed_turn, response.relay),
1512        })
1513    }
1514
1515    async fn device_certificate(
1516        &self,
1517        device_id: &str,
1518        device_profile: Option<&DeviceProfile>,
1519        attestation: Option<&dyn AttestationProvider>,
1520        identity_epoch: u64,
1521    ) -> Result<CredentialSource> {
1522        let device_key_thumbprint =
1523            native_device_key_thumbprint(&self.inner.signer.public_jwk(&self.inner.app_tag)?)?;
1524        let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1525            anyhow!("authenticated devices require a native identity assertion provider")
1526        })?;
1527        let session_key_hash = assertion_provider
1528            .session_key()?
1529            .filter(|value| !value.trim().is_empty())
1530            .map(|value| {
1531                native_session_key_hash(&self.inner.app_tag, &device_key_thumbprint, value.trim())
1532            });
1533        if let Some(session_key_hash) = session_key_hash.as_deref() {
1534            if let Some(cached) = self
1535                .inner
1536                .certificate_store
1537                .load_for_session(&self.inner.app_tag, session_key_hash, device_id)
1538                .await?
1539                .filter(|value| certificate_is_reusable(value, &device_key_thumbprint, now_ms()))
1540            {
1541                return Ok(CredentialSource {
1542                    relay: relay_availability_from_token(&cached.token)?,
1543                    token: cached.token,
1544                    expires_at_ms: cached.expires_at_ms,
1545                    principal_id: cached.principal_id,
1546                });
1547            }
1548        }
1549
1550        let identity = self
1551            .identity_for_epoch(identity_epoch, device_id, false)
1552            .await?;
1553        let principal_id = bounded_id("principal_id", identity.response.principal_id.clone())?;
1554
1555        if let Some(cached) = self
1556            .inner
1557            .certificate_store
1558            .load(&self.inner.app_tag, &principal_id, device_id)
1559            .await?
1560            .filter(|value| certificate_is_reusable(value, &device_key_thumbprint, now_ms()))
1561        {
1562            return Ok(CredentialSource {
1563                relay: relay_availability_from_token(&cached.token)?,
1564                token: cached.token,
1565                expires_at_ms: cached.expires_at_ms,
1566                principal_id,
1567            });
1568        }
1569
1570        // Identity sessions are intentionally short-lived. Reuse one only
1571        // inside the same host-declared login epoch, and exchange again only
1572        // when enrollment actually needs a fresh session. The provider's own
1573        // cached assertion is tried first; force refresh remains 401-driven.
1574        let identity = if identity.expires_at_ms > now_ms().saturating_add(30_000) {
1575            identity
1576        } else {
1577            self.identity_for_epoch(identity_epoch, device_id, true)
1578                .await?
1579        };
1580        if identity.response.principal_id != principal_id {
1581            bail!("native identity principal changed without advancing its login epoch");
1582        }
1583
1584        let enrollment = match self
1585            .enroll_device(
1586                &identity.response.identity_session,
1587                &principal_id,
1588                device_id,
1589                device_profile,
1590                attestation,
1591                false,
1592            )
1593            .await
1594        {
1595            Ok(enrollment) => enrollment,
1596            Err(error) if is_native_device_key_recovery_required(&error) => {
1597                let recovery_identity = self.exchange_recovery_identity(device_id).await?;
1598                if recovery_identity.principal_id != principal_id {
1599                    bail!("native identity principal changed during device-key recovery");
1600                }
1601                self.enroll_device(
1602                    &recovery_identity.identity_session,
1603                    &principal_id,
1604                    device_id,
1605                    device_profile,
1606                    attestation,
1607                    true,
1608                )
1609                .await
1610                .context("recover native OpenRTC device key")?
1611            }
1612            Err(error) => return Err(error).context("enroll native OpenRTC device"),
1613        };
1614        let expires_at_ms = enrollment
1615            .expires_at
1616            .checked_mul(1_000)
1617            .ok_or_else(|| anyhow!("device certificate expiry overflow"))?;
1618        let stored = StoredCertificate {
1619            app_tag: self.inner.app_tag.clone(),
1620            principal_id: principal_id.clone(),
1621            device_id: device_id.to_string(),
1622            token: required("device certificate", enrollment.device_certificate)?,
1623            expires_at_ms,
1624            session_key_hash,
1625            signing_public_jwk: Some(enrollment.signing_public_jwk),
1626        };
1627        validate_stored_certificate(&stored, &device_key_thumbprint)?;
1628        self.inner.certificate_store.save(&stored).await?;
1629        Ok(CredentialSource {
1630            relay: relay_availability_from_token(&stored.token)?,
1631            token: stored.token,
1632            expires_at_ms,
1633            principal_id,
1634        })
1635    }
1636
1637    async fn enroll_device(
1638        &self,
1639        identity_session: &str,
1640        principal_id: &str,
1641        device_id: &str,
1642        device_profile: Option<&DeviceProfile>,
1643        attestation: Option<&dyn AttestationProvider>,
1644        recover_device_key: bool,
1645    ) -> Result<DeviceEnrollmentResponse> {
1646        let proof = self.device_proof(|nonce, issued_at| {
1647            format!(
1648                "openrtc:v2:device-enroll:{}:{}:{}:{}:{}",
1649                self.inner.app_tag, principal_id, device_id, nonce, issued_at
1650            )
1651        })?;
1652        let attestation_value = if let Some(provider) = attestation {
1653            let challenge = format!(
1654                "openrtc:v2:attestation:{}:{}:{}:{}",
1655                self.inner.app_tag, principal_id, device_id, proof.nonce
1656            );
1657            Some(provider.evidence(&challenge, &self.inner.api_key).await?)
1658        } else {
1659            None
1660        };
1661        let mut body = json!({
1662            "identitySession": identity_session,
1663            "deviceId": device_id,
1664            "deviceProof": proof,
1665        });
1666        if let Some(device_profile) = device_profile {
1667            body["deviceProfile"] = json!(device_profile);
1668        }
1669        if let Some(attestation) = attestation_value {
1670            body["attestation"] = json!(attestation);
1671        }
1672        if recover_device_key {
1673            body["recoverDeviceKey"] = json!(true);
1674        }
1675        self.post("/v2/devices/enroll", body).await
1676    }
1677
1678    async fn exchange_recovery_identity(
1679        &self,
1680        device_id: &str,
1681    ) -> Result<AssertionExchangeResponse> {
1682        let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1683            anyhow!("authenticated devices require a native identity assertion provider")
1684        })?;
1685        let assertion = assertion_provider
1686            .assertion_for_device_recovery(device_id)
1687            .await
1688            .context("obtain consumer device-key recovery assertion")?;
1689        let assertion_token = required("identity assertion", assertion.token)?;
1690        let mut body = json!({ "assertion": assertion_token });
1691        if let Some(provider_id) = assertion.provider_id {
1692            body["providerId"] = json!(provider_id);
1693        }
1694        self.post("/v2/assertions/exchange", body)
1695            .await
1696            .context("exchange consumer device-key recovery assertion")
1697    }
1698
1699    async fn exchange_identity_after_rejection(
1700        &self,
1701        force_assertion_refresh: bool,
1702        device_id: &str,
1703    ) -> Result<AssertionExchangeResponse> {
1704        Ok(
1705            match self
1706                .exchange_identity(force_assertion_refresh, device_id)
1707                .await
1708            {
1709                Ok(identity) => identity,
1710                Err(error)
1711                    if !force_assertion_refresh
1712                        && error
1713                            .downcast_ref::<ControlPlaneHttpError>()
1714                            .is_some_and(|failure| failure.status == 401) =>
1715                {
1716                    // Match the browser/WASM contract: use the consumer's cached
1717                    // identity first and request one fresh assertion only after an
1718                    // explicit rejection. No timer or unbounded validation loop is
1719                    // introduced.
1720                    self.exchange_identity(true, device_id).await?
1721                }
1722                Err(error) => return Err(error),
1723            },
1724        )
1725    }
1726
1727    async fn identity_for_epoch(
1728        &self,
1729        identity_epoch: u64,
1730        device_id: &str,
1731        require_unexpired_session: bool,
1732    ) -> Result<CachedIdentity> {
1733        let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1734            anyhow!("authenticated devices require a native identity assertion provider")
1735        })?;
1736        if assertion_provider.identity_epoch() != identity_epoch {
1737            bail!("native identity changed before OpenRTC capability activation");
1738        }
1739        let mut state = self.inner.auth_epoch.lock().await;
1740        if state.epoch != Some(identity_epoch) {
1741            state.epoch = Some(identity_epoch);
1742            state.identity = None;
1743        }
1744        if let Some(identity) = state
1745            .identity
1746            .clone()
1747            .filter(|identity| identity.device_id == device_id)
1748        {
1749            if !require_unexpired_session
1750                || identity.expires_at_ms > now_ms().saturating_add(30_000)
1751            {
1752                if assertion_provider.identity_epoch() != identity_epoch {
1753                    state.identity = None;
1754                    bail!("native identity changed during capability activation");
1755                }
1756                return Ok(identity);
1757            }
1758        }
1759        let response = self
1760            .exchange_identity_after_rejection(false, device_id)
1761            .await?;
1762        if assertion_provider.identity_epoch() != identity_epoch {
1763            state.identity = None;
1764            bail!("native identity changed during assertion exchange");
1765        }
1766        let expires_at_ms = response
1767            .expires_at
1768            .checked_mul(1_000)
1769            .ok_or_else(|| anyhow!("identity session expiry overflow"))?;
1770        let identity = CachedIdentity {
1771            response,
1772            expires_at_ms,
1773            device_id: device_id.to_string(),
1774        };
1775        state.identity = Some(identity.clone());
1776        Ok(identity)
1777    }
1778
1779    async fn exchange_identity(
1780        &self,
1781        force_refresh: bool,
1782        device_id: &str,
1783    ) -> Result<AssertionExchangeResponse> {
1784        let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1785            anyhow!("authenticated devices require a native identity assertion provider")
1786        })?;
1787        let assertion = assertion_provider
1788            .assertion_for_device(force_refresh, device_id)
1789            .await
1790            .context("obtain consumer identity assertion")?;
1791        let assertion_token = required("identity assertion", assertion.token)?;
1792        let mut assertion_body = json!({ "assertion": assertion_token });
1793        if let Some(provider_id) = assertion.provider_id {
1794            assertion_body["providerId"] = json!(provider_id);
1795        }
1796        self.post("/v2/assertions/exchange", assertion_body)
1797            .await
1798            .context("exchange consumer identity assertion")
1799    }
1800
1801    fn device_proof(
1802        &self,
1803        challenge: impl FnOnce(&str, u64) -> String,
1804    ) -> Result<NativeDeviceProof> {
1805        let public_key_jwk = self.inner.signer.public_jwk(&self.inner.app_tag)?;
1806        validate_public_jwk(&public_key_jwk)?;
1807        let nonce = Uuid::new_v4().simple().to_string();
1808        let issued_at = now_seconds();
1809        let challenge = challenge(&nonce, issued_at);
1810        let signature = self
1811            .inner
1812            .signer
1813            .sign(&self.inner.app_tag, challenge.as_bytes())?;
1814        if signature.len() != 64 {
1815            bail!("native device signer returned an invalid Ed25519 signature");
1816        }
1817        Ok(NativeDeviceProof {
1818            public_key_jwk,
1819            signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature),
1820            nonce,
1821            issued_at,
1822        })
1823    }
1824
1825    async fn post<T: for<'de> Deserialize<'de>>(&self, path: &str, body: Value) -> Result<T> {
1826        let mut object = body
1827            .as_object()
1828            .cloned()
1829            .ok_or_else(|| anyhow!("native control-plane body must be an object"))?;
1830        object.insert("apiKey".to_string(), json!(self.inner.api_key));
1831        self.post_json(path, Value::Object(object), None).await
1832    }
1833
1834    async fn post_with_bearer<T: for<'de> Deserialize<'de>>(
1835        &self,
1836        path: &str,
1837        body: Value,
1838        bearer: &str,
1839    ) -> Result<T> {
1840        if !bearer.starts_with("sk_") || bearer.len() > 128 {
1841            bail!("native authority service secret is invalid");
1842        }
1843        self.post_json(path, body, Some(bearer)).await
1844    }
1845
1846    async fn post_json<T: for<'de> Deserialize<'de>>(
1847        &self,
1848        path: &str,
1849        body: Value,
1850        bearer: Option<&str>,
1851    ) -> Result<T> {
1852        let url = format!("{}{}", self.inner.endpoint.trim_end_matches('/'), path);
1853        let request_id = format!("native_{}", Uuid::new_v4().simple());
1854        let serialized = serde_json::to_vec(&body).context("encode OpenRTC request")?;
1855
1856        for attempt in 0..2 {
1857            let mut request = self
1858                .inner
1859                .http
1860                .post(&url)
1861                .header("X-OpenRTC-Idempotency-Key", &request_id)
1862                .header(reqwest::header::CONTENT_TYPE, "application/json")
1863                .body(serialized.clone());
1864            if let Some(bearer) = bearer {
1865                request = request.bearer_auth(bearer);
1866            }
1867            let response = request.send().await;
1868            let response = match response {
1869                Ok(response) => response,
1870                Err(_error) if attempt == 0 => {
1871                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1872                    continue;
1873                }
1874                Err(error) => return Err(error).context("send OpenRTC request"),
1875            };
1876            let status = response.status();
1877            if !status.is_success() {
1878                let payload = response.json::<Value>().await.unwrap_or(Value::Null);
1879                let message = payload
1880                    .get("error")
1881                    .and_then(Value::as_str)
1882                    .unwrap_or("OpenRTC control-plane request failed")
1883                    .chars()
1884                    .take(256)
1885                    .collect::<String>();
1886                let error = ControlPlaneHttpError {
1887                    service_error: crate::service_errors::ServiceError::from_value(&payload),
1888                    status: status.as_u16(),
1889                    message,
1890                    reason: payload
1891                        .get("reason")
1892                        .and_then(Value::as_str)
1893                        .filter(|value| {
1894                            !value.is_empty()
1895                                && value.len() <= 80
1896                                && value.bytes().all(|byte| {
1897                                    byte.is_ascii_lowercase()
1898                                        || byte.is_ascii_digit()
1899                                        || byte == b'-'
1900                                })
1901                        })
1902                        .map(str::to_string),
1903                };
1904                // Preserve the legacy transient HTTP fallback, but do not
1905                // retry a typed service denial before its retry policy can
1906                // reach the existing lifecycle owner.
1907                if attempt == 0
1908                    && error.service_error.is_none()
1909                    && matches!(
1910                        status,
1911                        reqwest::StatusCode::BAD_GATEWAY
1912                            | reqwest::StatusCode::SERVICE_UNAVAILABLE
1913                            | reqwest::StatusCode::GATEWAY_TIMEOUT
1914                    )
1915                {
1916                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1917                    continue;
1918                }
1919                return Err(anyhow::Error::new(error));
1920            }
1921            return response
1922                .json::<T>()
1923                .await
1924                .context("decode OpenRTC response");
1925        }
1926        unreachable!("native control-plane retry loop has two terminal attempts")
1927    }
1928}
1929
1930fn is_native_device_key_recovery_required(error: &anyhow::Error) -> bool {
1931    error
1932        .downcast_ref::<ControlPlaneHttpError>()
1933        .is_some_and(|failure| {
1934            failure.status == 412
1935                && failure.reason.as_deref() == Some("device-key-recovery-required")
1936        })
1937}
1938
1939fn spawn_identity_epoch_monitor(
1940    mut receiver: watch::Receiver<u64>,
1941    identity_epoch: u64,
1942    closer: crate::native_coordination_gateway::NativeCapabilityCloser,
1943    relay: CredentialRelay,
1944) -> tokio::task::JoinHandle<()> {
1945    tokio::spawn(async move {
1946        loop {
1947            if receiver.changed().await.is_err() {
1948                return;
1949            }
1950            if *receiver.borrow_and_update() != identity_epoch {
1951                let _ = relay.set(None);
1952                closer.close().await;
1953                return;
1954            }
1955        }
1956    })
1957}
1958
1959struct ControlPlaneGrantProvider {
1960    control_plane: ControlPlane,
1961    device_id: String,
1962    source: Mutex<CredentialSource>,
1963    max_peers: Option<u32>,
1964    features: Features,
1965    device_profile: Option<DeviceProfile>,
1966    attestation: Option<Arc<dyn AttestationProvider>>,
1967    identity_relay: CredentialRelay,
1968    identity_epoch: u64,
1969}
1970
1971struct AnonymousControlPlaneGrantProvider {
1972    control_plane: ControlPlane,
1973    source: Mutex<AnonymousCredentialSource>,
1974    kind: AnonymousKind,
1975    max_peers: Option<u32>,
1976    features: Features,
1977    architecture: Option<RoomArchitectureMode>,
1978    room_delivery: Option<RoomDelivery>,
1979}
1980
1981struct AuthorityServiceGrantProvider {
1982    control_plane: ControlPlane,
1983    secret_key: String,
1984    service_id: String,
1985    generation: u64,
1986    shard_ids: Vec<String>,
1987    assignment_public_jwk: Value,
1988    device_id: String,
1989    max_peers: Option<u32>,
1990    features: Features,
1991}
1992
1993#[async_trait]
1994impl NativeGatewayGrantProvider for ControlPlaneGrantProvider {
1995    async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
1996        if request.device_id != self.device_id {
1997            bail!("gateway grant request device does not match enrolled device");
1998        }
1999        let mut source = self.source.lock().await;
2000        if source.expires_at_ms <= now_ms().saturating_add(SOURCE_RENEW_SKEW_MS) {
2001            *source = self
2002                .control_plane
2003                .device_certificate(
2004                    &self.device_id,
2005                    self.device_profile.as_ref(),
2006                    self.attestation.as_deref(),
2007                    self.identity_epoch,
2008                )
2009                .await?;
2010            self.identity_relay.set(Some(source.token.clone()))?;
2011        }
2012        let source_jti = required_claim_string(&source.token, "jti")?;
2013        let proof = self.control_plane.device_proof(|nonce, issued_at| {
2014            format!(
2015                "openrtc:v2:gateway-grant:{}:{}:{}:{}:{}:{}",
2016                source_jti,
2017                request.avenue.kind,
2018                request.avenue.id,
2019                request.runtime_instance_id,
2020                nonce,
2021                issued_at
2022            )
2023        })?;
2024        let mut grant_body = json!({
2025            "credentialType": "device-certificate",
2026            "credential": source.token,
2027            "avenue": request.avenue,
2028            "runtimeInstanceId": request.runtime_instance_id,
2029            "ticketFingerprint": request.ticket_fingerprint,
2030            "deviceProof": proof,
2031            "features": available_features(self.features.clone(), source.relay),
2032        });
2033        if let Some(refresh_grant) = request.refresh_grant {
2034            grant_body["refreshGrant"] = json!(refresh_grant);
2035        }
2036        if let Some(max_peers) = self.max_peers {
2037            grant_body["maxPeers"] = json!(max_peers);
2038        }
2039        if let Some(architecture) = request.architecture {
2040            grant_body["architecture"] = json!(architecture);
2041        }
2042        if let Some(room_delivery) = request.room_delivery {
2043            grant_body["roomDelivery"] = json!(room_delivery);
2044        }
2045        if let Some(device_profile) = &self.device_profile {
2046            grant_body["deviceProfile"] = json!(device_profile);
2047        }
2048        let response: GatewayGrantResponse = self
2049            .control_plane
2050            .post("/v2/gateway/grants", grant_body)
2051            .await?;
2052        if response.gateway_url.trim_end_matches('/')
2053            != self
2054                .control_plane
2055                .inner
2056                .gateway_endpoint
2057                .trim_end_matches('/')
2058        {
2059            bail!("OpenRTC returned an unexpected native gateway origin");
2060        }
2061        Ok(NativeGatewayGrant {
2062            protocol_version: response.protocol_version,
2063            gateway_url: response.gateway_url,
2064            route_key: response.route_key,
2065            token: response.token,
2066            expires_at_ms: response.expires_at_ms,
2067        })
2068    }
2069}
2070
2071#[async_trait]
2072impl NativeGatewayGrantProvider for AnonymousControlPlaneGrantProvider {
2073    async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2074        let mut source = self.source.lock().await;
2075        if request.device_id != source.device_id {
2076            bail!("gateway grant request device does not match the capability install key");
2077        }
2078        if source.expires_at_ms <= now_ms().saturating_add(SOURCE_RENEW_SKEW_MS) {
2079            *source = self
2080                .control_plane
2081                .anonymous_source(
2082                    self.kind,
2083                    source.requested_id.clone(),
2084                    self.max_peers,
2085                    self.architecture,
2086                    self.room_delivery,
2087                )
2088                .await?;
2089        }
2090        let expected_kind = match self.kind {
2091            AnonymousKind::Ticket => "session",
2092            _ => self.kind.source_kind(),
2093        };
2094        if request.avenue.kind != expected_kind || request.avenue.id != source.avenue_id {
2095            bail!("gateway grant request avenue does not match the capability");
2096        }
2097        let source_jti = required_claim_string(&source.token, "jti")?;
2098        let proof = self.control_plane.device_proof(|nonce, issued_at| {
2099            format!(
2100                "openrtc:v2:gateway-grant:{}:{}:{}:{}:{}:{}",
2101                source_jti,
2102                request.avenue.kind,
2103                request.avenue.id,
2104                request.runtime_instance_id,
2105                nonce,
2106                issued_at
2107            )
2108        })?;
2109        let mut grant_body = json!({
2110            "credentialType": "capability",
2111            "credential": source.token,
2112            "avenue": request.avenue,
2113            "runtimeInstanceId": request.runtime_instance_id,
2114            "ticketFingerprint": request.ticket_fingerprint,
2115            "deviceProof": proof,
2116            "features": available_features(self.features.clone(), source.relay),
2117        });
2118        if let Some(refresh_grant) = request.refresh_grant {
2119            grant_body["refreshGrant"] = json!(refresh_grant);
2120        }
2121        if let Some(max_peers) = self.max_peers {
2122            grant_body["maxPeers"] = json!(max_peers);
2123        }
2124        if let Some(architecture) = request.architecture {
2125            grant_body["architecture"] = json!(architecture);
2126        }
2127        if let Some(room_delivery) = request.room_delivery {
2128            grant_body["roomDelivery"] = json!(room_delivery);
2129        }
2130        let response: GatewayGrantResponse = self
2131            .control_plane
2132            .post("/v2/gateway/grants", grant_body)
2133            .await?;
2134        if response.gateway_url.trim_end_matches('/')
2135            != self
2136                .control_plane
2137                .inner
2138                .gateway_endpoint
2139                .trim_end_matches('/')
2140        {
2141            bail!("OpenRTC returned an unexpected native gateway origin");
2142        }
2143        Ok(NativeGatewayGrant {
2144            protocol_version: response.protocol_version,
2145            gateway_url: response.gateway_url,
2146            route_key: response.route_key,
2147            token: response.token,
2148            expires_at_ms: response.expires_at_ms,
2149        })
2150    }
2151}
2152
2153#[async_trait]
2154impl NativeGatewayGrantProvider for AuthorityServiceGrantProvider {
2155    async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2156        if request.device_id != self.device_id
2157            || request.avenue.kind != "room"
2158            || request.architecture != Some(RoomArchitectureMode::Authority)
2159        {
2160            bail!("authority service grant request scope is invalid");
2161        }
2162        let assignment_key_x = self
2163            .assignment_public_jwk
2164            .get("x")
2165            .and_then(Value::as_str)
2166            .ok_or_else(|| anyhow!("authority assignment public key is invalid"))?;
2167        let proof = self.control_plane.device_proof(|nonce, issued_at| {
2168            format!(
2169                "openrtc:v2:authority-service-grant:{}:{}:{}:{}:{}:{}:{}:{}:{}",
2170                self.control_plane.inner.app_tag,
2171                request.avenue.id,
2172                self.service_id,
2173                self.generation,
2174                self.shard_ids.join(","),
2175                assignment_key_x,
2176                request.runtime_instance_id,
2177                nonce,
2178                issued_at,
2179            )
2180        })?;
2181        let mut body = json!({
2182            "avenue": request.avenue,
2183            "serviceId": self.service_id,
2184            "generation": self.generation,
2185            "shardIds": self.shard_ids,
2186            "assignmentPublicKeyJwk": self.assignment_public_jwk,
2187            "deviceId": self.device_id,
2188            "runtimeInstanceId": request.runtime_instance_id,
2189            "ticketFingerprint": request.ticket_fingerprint,
2190            "deviceProof": proof,
2191            "features": self.features,
2192        });
2193        if let Some(max_peers) = self.max_peers {
2194            body["maxPeers"] = json!(max_peers);
2195        }
2196        if let Some(refresh_grant) = request.refresh_grant {
2197            body["refreshGrant"] = json!(refresh_grant);
2198        }
2199        let response: GatewayGrantResponse = self
2200            .control_plane
2201            .post_with_bearer("/v2/developer/authority/grants", body, &self.secret_key)
2202            .await?;
2203        if response.gateway_url.trim_end_matches('/')
2204            != self
2205                .control_plane
2206                .inner
2207                .gateway_endpoint
2208                .trim_end_matches('/')
2209        {
2210            bail!("OpenRTC returned an unexpected native gateway origin");
2211        }
2212        Ok(NativeGatewayGrant {
2213            protocol_version: response.protocol_version,
2214            gateway_url: response.gateway_url,
2215            route_key: response.route_key,
2216            token: response.token,
2217            expires_at_ms: response.expires_at_ms,
2218        })
2219    }
2220}
2221
2222#[derive(Debug, Serialize)]
2223#[serde(rename_all = "camelCase")]
2224struct NativeDeviceProof {
2225    public_key_jwk: Value,
2226    signature: String,
2227    nonce: String,
2228    issued_at: u64,
2229}
2230
2231#[derive(Debug, Clone, Deserialize)]
2232#[serde(rename_all = "camelCase")]
2233struct AssertionExchangeResponse {
2234    identity_session: String,
2235    principal_id: String,
2236    expires_at: u64,
2237}
2238
2239#[derive(Deserialize)]
2240#[serde(rename_all = "camelCase")]
2241struct DeviceEnrollmentResponse {
2242    device_certificate: String,
2243    expires_at: u64,
2244    signing_public_jwk: Value,
2245}
2246
2247#[derive(Deserialize)]
2248#[serde(rename_all = "camelCase")]
2249struct CapabilityResponse {
2250    capability: String,
2251    expires_at: u64,
2252    avenue: CapabilityAvenueResponse,
2253    #[serde(default)]
2254    iroh_relay: Option<bool>,
2255    #[serde(default)]
2256    managed_turn: bool,
2257    #[serde(default)]
2258    relay: Option<bool>,
2259}
2260
2261#[derive(Deserialize)]
2262struct CapabilityAvenueResponse {
2263    kind: String,
2264    id: String,
2265}
2266
2267#[derive(Deserialize)]
2268#[serde(rename_all = "camelCase")]
2269struct GatewayGrantResponse {
2270    protocol_version: u8,
2271    gateway_url: String,
2272    route_key: String,
2273    token: String,
2274    expires_at_ms: u64,
2275}
2276
2277fn now_seconds() -> u64 {
2278    SystemTime::now()
2279        .duration_since(UNIX_EPOCH)
2280        .unwrap_or_default()
2281        .as_secs()
2282}
2283
2284fn now_ms() -> u64 {
2285    now_seconds().saturating_mul(1_000)
2286}
2287
2288fn required(field: &str, value: String) -> Result<String> {
2289    let value = value.trim().to_string();
2290    if value.is_empty() {
2291        bail!("{field} is required");
2292    }
2293    Ok(value)
2294}
2295
2296fn bounded_id(field: &str, value: String) -> Result<String> {
2297    let value = required(field, value)?;
2298    if value.len() > 160
2299        || !value.bytes().all(|byte| {
2300            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'@')
2301        })
2302    {
2303        bail!("{field} is invalid");
2304    }
2305    Ok(value)
2306}
2307
2308fn bounded_visible_text(field: &str, value: String, max_len: usize) -> Result<String> {
2309    let value = required(field, value)?;
2310    if value.len() > max_len || value.chars().any(char::is_control) {
2311        bail!("{field} is invalid");
2312    }
2313    Ok(value)
2314}
2315
2316fn normalized_device_profile(
2317    profile: Option<DeviceProfile>,
2318    platform_type: &str,
2319) -> Result<Option<DeviceProfile>> {
2320    let profile = profile.unwrap_or_default();
2321    let name = profile
2322        .name
2323        .map(|value| bounded_visible_text("device_profile.name", value, 120))
2324        .transpose()?;
2325    let platform = Some(bounded_visible_text(
2326        "device_profile.platform",
2327        profile
2328            .platform
2329            .unwrap_or_else(|| platform_type.to_string()),
2330        80,
2331    )?);
2332    Ok(Some(DeviceProfile { name, platform }))
2333}
2334
2335fn native_capability_avenue_id(api_key: &str, kind: AnonymousKind, requested_id: &str) -> String {
2336    if kind != AnonymousKind::Space {
2337        return requested_id.to_string();
2338    }
2339    let digest =
2340        <sha2::Sha256 as sha2::Digest>::digest(format!("{api_key}:{requested_id}").as_bytes());
2341    hex::encode(digest)
2342}
2343
2344#[cfg(any(test, feature = "testing-endpoints"))]
2345fn validate_endpoint(value: String) -> Result<String> {
2346    let parsed = reqwest::Url::parse(value.trim())?;
2347    if !matches!(parsed.scheme(), "https" | "http")
2348        || parsed.host_str().is_none()
2349        || !parsed.username().is_empty()
2350        || parsed.password().is_some()
2351        || parsed.query().is_some()
2352        || parsed.fragment().is_some()
2353    {
2354        bail!("OpenRTC testing endpoint is invalid");
2355    }
2356    Ok(parsed.as_str().trim_end_matches('/').to_string())
2357}
2358
2359fn validate_public_jwk(value: &Value) -> Result<()> {
2360    let object = value
2361        .as_object()
2362        .ok_or_else(|| anyhow!("native device public key is invalid"))?;
2363    let x = object.get("x").and_then(Value::as_str).unwrap_or_default();
2364    if object.get("kty").and_then(Value::as_str) != Some("OKP")
2365        || object.get("crv").and_then(Value::as_str) != Some("Ed25519")
2366        || object.contains_key("d")
2367        || x.len() != 43
2368        || !x
2369            .bytes()
2370            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
2371    {
2372        bail!("native device public key must be a public Ed25519 JWK");
2373    }
2374    Ok(())
2375}
2376
2377fn decode_claims(token: &str) -> Result<Value> {
2378    let payload = token
2379        .split('.')
2380        .nth(1)
2381        .ok_or_else(|| anyhow!("OpenRTC credential is malformed"))?;
2382    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2383        .decode(payload)
2384        .context("decode OpenRTC credential claims")?;
2385    serde_json::from_slice(&bytes).context("parse OpenRTC credential claims")
2386}
2387
2388fn required_claim_string(token: &str, name: &str) -> Result<String> {
2389    decode_claims(token)?
2390        .get(name)
2391        .and_then(Value::as_str)
2392        .map(str::to_string)
2393        .filter(|value| !value.is_empty())
2394        .ok_or_else(|| anyhow!("OpenRTC credential is missing {name}"))
2395}
2396
2397fn native_device_key_thumbprint(public_key_jwk: &Value) -> Result<String> {
2398    validate_public_jwk(public_key_jwk)?;
2399    let canonical = json!({
2400        "crv": public_key_jwk.get("crv").and_then(Value::as_str),
2401        "kty": public_key_jwk.get("kty").and_then(Value::as_str),
2402        "x": public_key_jwk.get("x").and_then(Value::as_str),
2403    });
2404    let bytes = serde_json::to_vec(&canonical)?;
2405    let digest = <sha2::Sha256 as sha2::Digest>::digest(bytes);
2406    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest))
2407}
2408
2409fn native_session_key_hash(
2410    app_tag: &str,
2411    device_key_thumbprint: &str,
2412    session_key: &str,
2413) -> String {
2414    let digest = <sha2::Sha256 as sha2::Digest>::digest(
2415        format!(
2416            "openrtc:v2:session\0{}\0{}\0{}",
2417            app_tag, device_key_thumbprint, session_key,
2418        )
2419        .as_bytes(),
2420    );
2421    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
2422}
2423
2424fn validate_stored_certificate(
2425    value: &StoredCertificate,
2426    device_key_thumbprint: &str,
2427) -> Result<()> {
2428    let signing_public_jwk = value
2429        .signing_public_jwk
2430        .as_ref()
2431        .ok_or_else(|| anyhow!("stored OpenRTC device certificate has no verification key"))?;
2432    verify_device_certificate_signature(&value.token, signing_public_jwk)?;
2433    let claims = decode_claims(&value.token)?;
2434    let issued_at = claims.get("iat").and_then(Value::as_u64);
2435    let expires_at = claims.get("exp").and_then(Value::as_u64);
2436    if claims.get("iss").and_then(Value::as_str) != Some("openrtc")
2437        || claims.get("aud").and_then(Value::as_str) != Some("openrtc:v2:gateway-grant")
2438        || claims.get("typ").and_then(Value::as_str) != Some("device-certificate")
2439        || claims.get("appTag").and_then(Value::as_str) != Some(value.app_tag.as_str())
2440        || claims.get("principalId").and_then(Value::as_str) != Some(value.principal_id.as_str())
2441        || claims.get("principalKey").and_then(Value::as_str).is_none()
2442        || claims.get("deviceId").and_then(Value::as_str) != Some(value.device_id.as_str())
2443        || claims.get("deviceKeyThumbprint").and_then(Value::as_str) != Some(device_key_thumbprint)
2444        || claims.get("jti").and_then(Value::as_str).is_none()
2445        || issued_at.is_none()
2446        || expires_at.and_then(|exp| exp.checked_mul(1_000)) != Some(value.expires_at_ms)
2447        || issued_at
2448            .zip(expires_at)
2449            .is_none_or(|(iat, exp)| iat >= exp)
2450    {
2451        bail!("stored OpenRTC device certificate scope is invalid");
2452    }
2453    Ok(())
2454}
2455
2456fn verify_device_certificate_signature(token: &str, public_jwk: &Value) -> Result<()> {
2457    let mut parts = token.split('.');
2458    let header_segment = parts
2459        .next()
2460        .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2461    let claims_segment = parts
2462        .next()
2463        .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2464    let signature_segment = parts
2465        .next()
2466        .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2467    if parts.next().is_some() {
2468        bail!("invalid certificate JWT");
2469    }
2470    let header: Value = serde_json::from_slice(
2471        &base64::engine::general_purpose::URL_SAFE_NO_PAD
2472            .decode(header_segment)
2473            .context("decode certificate header")?,
2474    )?;
2475    if header.get("alg").and_then(Value::as_str) != Some("EdDSA")
2476        || public_jwk.get("kty").and_then(Value::as_str) != Some("OKP")
2477        || public_jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
2478        || header.get("kid").and_then(Value::as_str)
2479            != public_jwk.get("kid").and_then(Value::as_str)
2480    {
2481        bail!("unsupported OpenRTC certificate signing key");
2482    }
2483    let public_key = base64::engine::general_purpose::URL_SAFE_NO_PAD
2484        .decode(
2485            public_jwk
2486                .get("x")
2487                .and_then(Value::as_str)
2488                .ok_or_else(|| anyhow!("certificate signing key is missing x"))?,
2489        )
2490        .context("decode certificate signing key")?;
2491    let public_key: [u8; 32] = public_key
2492        .try_into()
2493        .map_err(|_| anyhow!("certificate signing key has invalid length"))?;
2494    let verifying_key =
2495        VerifyingKey::from_bytes(&public_key).context("parse certificate signing key")?;
2496    let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
2497        .decode(signature_segment)
2498        .context("decode certificate signature")?;
2499    let signature = Signature::from_slice(&signature).context("parse certificate signature")?;
2500    verifying_key
2501        .verify(
2502            format!("{header_segment}.{claims_segment}").as_bytes(),
2503            &signature,
2504        )
2505        .context("verify OpenRTC device certificate")
2506}
2507
2508fn certificate_is_reusable(
2509    value: &StoredCertificate,
2510    device_key_thumbprint: &str,
2511    at_ms: u64,
2512) -> bool {
2513    value.expires_at_ms > at_ms.saturating_add(DEVICE_CERTIFICATE_RENEW_SKEW_MS)
2514        && validate_stored_certificate(value, device_key_thumbprint).is_ok()
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519    use super::*;
2520    use ed25519_dalek::{Signer as _, SigningKey};
2521    use std::sync::atomic::{AtomicU64, Ordering};
2522    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2523    use tokio::net::TcpListener;
2524
2525    struct TestSigner;
2526
2527    struct NeverGrantProvider;
2528
2529    #[async_trait]
2530    impl NativeGatewayGrantProvider for NeverGrantProvider {
2531        async fn grant(&self, _request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2532            bail!("inactive epoch-retirement test must not request a gateway grant")
2533        }
2534    }
2535
2536    #[derive(Default)]
2537    struct RecordingAssertionProvider {
2538        force_refreshes: StdMutex<Vec<bool>>,
2539        device_ids: StdMutex<Vec<String>>,
2540        identity_epoch: AtomicU64,
2541    }
2542
2543    #[async_trait]
2544    impl AssertionProvider for RecordingAssertionProvider {
2545        async fn assertion(&self, force_refresh: bool) -> Result<IdentityAssertion> {
2546            self.force_refreshes.lock().unwrap().push(force_refresh);
2547            Ok(IdentityAssertion {
2548                token: if force_refresh { "fresh" } else { "stale" }.to_string(),
2549                provider_id: None,
2550            })
2551        }
2552
2553        async fn assertion_for_device(
2554            &self,
2555            force_refresh: bool,
2556            device_id: &str,
2557        ) -> Result<IdentityAssertion> {
2558            self.device_ids.lock().unwrap().push(device_id.to_string());
2559            self.assertion(force_refresh).await
2560        }
2561
2562        fn identity_epoch(&self) -> u64 {
2563            self.identity_epoch.load(Ordering::Acquire)
2564        }
2565    }
2566
2567    impl DeviceSigner for TestSigner {
2568        fn public_jwk(&self, _app_tag: &str) -> Result<Value> {
2569            Ok(json!({
2570                "kty": "OKP",
2571                "crv": "Ed25519",
2572                "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2573            }))
2574        }
2575
2576        fn sign(&self, _app_tag: &str, _challenge: &[u8]) -> Result<Vec<u8>> {
2577            Ok(vec![0; 64])
2578        }
2579    }
2580
2581    fn signed_certificate(
2582        app_tag: &str,
2583        principal_id: &str,
2584        device_id: &str,
2585        expires_at: u64,
2586    ) -> (String, Value) {
2587        let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
2588        let kid = "test-signing-key";
2589        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
2590            serde_json::to_vec(&json!({ "alg": "EdDSA", "typ": "JWT", "kid": kid })).unwrap(),
2591        );
2592        let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
2593            serde_json::to_vec(&json!({
2594                "iss": "openrtc",
2595                "aud": "openrtc:v2:gateway-grant",
2596                "typ": "device-certificate",
2597                "appTag": app_tag,
2598                "principalId": principal_id,
2599                "principalKey": "principal-key",
2600                "deviceId": device_id,
2601                "deviceKeyThumbprint": "thumbprint",
2602                "iat": expires_at - 60,
2603                "exp": expires_at,
2604                "jti": "test-jti",
2605            }))
2606            .unwrap(),
2607        );
2608        let signing_input = format!("{header}.{claims}");
2609        let signature = signing_key.sign(signing_input.as_bytes());
2610        let token = format!(
2611            "{signing_input}.{}",
2612            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes())
2613        );
2614        let public_jwk = json!({
2615            "kty": "OKP",
2616            "crv": "Ed25519",
2617            "alg": "EdDSA",
2618            "kid": kid,
2619            "x": base64::engine::general_purpose::URL_SAFE_NO_PAD
2620                .encode(signing_key.verifying_key().to_bytes()),
2621        });
2622        (token, public_jwk)
2623    }
2624
2625    #[test]
2626    fn cached_certificate_is_scope_bound_and_renews_a_day_early() {
2627        let now = now_ms();
2628        let expires_at = now / 1_000 + 2 * 24 * 60 * 60;
2629        let (token, public_jwk) =
2630            signed_certificate("app_0000000000000000", "principal", "device", expires_at);
2631        let value = StoredCertificate {
2632            app_tag: "app_0000000000000000".to_string(),
2633            principal_id: "principal".to_string(),
2634            device_id: "device".to_string(),
2635            token,
2636            expires_at_ms: expires_at * 1_000,
2637            session_key_hash: Some("session-hash".to_string()),
2638            signing_public_jwk: Some(public_jwk),
2639        };
2640        assert!(certificate_is_reusable(&value, "thumbprint", now));
2641        assert!(!certificate_is_reusable(
2642            &value,
2643            "thumbprint",
2644            value.expires_at_ms - DEVICE_CERTIFICATE_RENEW_SKEW_MS + 1,
2645        ));
2646
2647        let mut wrong = value.clone();
2648        wrong.device_id = "other-device".to_string();
2649        assert!(!certificate_is_reusable(&wrong, "thumbprint", now));
2650        assert!(!certificate_is_reusable(&value, "rotated-thumbprint", now));
2651
2652        let mut tampered = value.clone();
2653        tampered.principal_id = "attacker".to_string();
2654        assert!(!certificate_is_reusable(&tampered, "thumbprint", now));
2655
2656        let mut missing_key = value.clone();
2657        missing_key.signing_public_jwk = None;
2658        assert!(!certificate_is_reusable(&missing_key, "thumbprint", now));
2659    }
2660
2661    #[test]
2662    fn device_public_jwk_rejects_private_material() {
2663        assert!(validate_public_jwk(&json!({
2664            "kty": "OKP",
2665            "crv": "Ed25519",
2666            "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2667        }))
2668        .is_ok());
2669        assert!(validate_public_jwk(&json!({
2670            "kty": "OKP",
2671            "crv": "Ed25519",
2672            "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2673            "d": "secret",
2674        }))
2675        .is_err());
2676    }
2677
2678    #[test]
2679    fn native_device_profile_defaults_platform_and_rejects_control_text() {
2680        assert_eq!(
2681            normalized_device_profile(None, "ios").unwrap(),
2682            Some(DeviceProfile {
2683                name: None,
2684                platform: Some("ios".to_string()),
2685            }),
2686        );
2687        assert_eq!(
2688            normalized_device_profile(
2689                Some(DeviceProfile {
2690                    name: Some("Bryant's iPhone".to_string()),
2691                    platform: None,
2692                }),
2693                "ios",
2694            )
2695            .unwrap(),
2696            Some(DeviceProfile {
2697                name: Some("Bryant's iPhone".to_string()),
2698                platform: Some("ios".to_string()),
2699            }),
2700        );
2701        assert!(normalized_device_profile(
2702            Some(DeviceProfile {
2703                name: Some("unsafe\nname".to_string()),
2704                platform: Some("ios".to_string()),
2705            }),
2706            "ios",
2707        )
2708        .is_err());
2709    }
2710
2711    #[test]
2712    fn native_relay_capabilities_do_not_infer_managed_turn_from_legacy_relay() {
2713        let legacy = relay_availability(None, false, Some(true));
2714        assert_eq!(
2715            legacy,
2716            RelayAvailability {
2717                iroh_relay: true,
2718                managed_turn: false,
2719            }
2720        );
2721
2722        let requested = Features {
2723            iroh_relay: true,
2724            managed_turn: true,
2725            ..Features::default()
2726        };
2727        assert_eq!(
2728            available_features(requested, legacy),
2729            Features {
2730                iroh_relay: true,
2731                managed_turn: false,
2732                ..Features::default()
2733            }
2734        );
2735    }
2736
2737    #[test]
2738    fn native_service_error_retryability_overrides_http_status() {
2739        let mut error = ControlPlaneHttpError {
2740            status: 429,
2741            message: "paused".into(),
2742            reason: None,
2743            service_error: None,
2744        };
2745        assert!(error.is_retryable());
2746        error.service_error = crate::service_errors::ServiceError::from_value(&serde_json::json!({
2747            "code": "credit-exhausted", "retryable": false, "scope": "account", "requestId": "request-1"
2748        }));
2749        assert!(!error.is_retryable());
2750        error.status = 503;
2751        assert!(!error.is_retryable());
2752        error.service_error = crate::service_errors::ServiceError::from_value(&serde_json::json!({
2753            "code": "app-rate-limited", "retryable": true, "retryAfterMs": 2300
2754        }));
2755        assert!(error.is_retryable());
2756        assert_eq!(error.service_error.unwrap().retry_after_ms, Some(2300));
2757    }
2758
2759    #[test]
2760    fn native_device_key_recovery_requires_exact_status_and_reason() {
2761        let required = anyhow::Error::new(ControlPlaneHttpError {
2762            service_error: None,
2763            status: 412,
2764            message: "recovery required".to_string(),
2765            reason: Some("device-key-recovery-required".to_string()),
2766        });
2767        assert!(is_native_device_key_recovery_required(&required));
2768
2769        for (status, reason) in [
2770            (403, Some("device-key-recovery-required")),
2771            (412, Some("device-certificate-revoked")),
2772            (412, None),
2773        ] {
2774            let unrelated = anyhow::Error::new(ControlPlaneHttpError {
2775                service_error: None,
2776                status,
2777                message: "unrelated".to_string(),
2778                reason: reason.map(str::to_string),
2779            });
2780            assert!(!is_native_device_key_recovery_required(&unrelated));
2781        }
2782    }
2783
2784    #[tokio::test]
2785    async fn authority_capacity_is_bounded_before_network_or_runtime_start() {
2786        let control =
2787            ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2788                .unwrap();
2789        for max_peers in [
2790            None,
2791            Some(0),
2792            Some(1),
2793            Some(8),
2794            Some(50),
2795            Some(51),
2796            Some(5_000),
2797        ] {
2798            let result = control
2799                .join_authority_room(
2800                    "sk_test_service",
2801                    "room",
2802                    "service",
2803                    "native",
2804                    RoomAuthorityOptions {
2805                        service_id: "authority".into(),
2806                        generation: 1,
2807                        shard_ids: vec!["main".into()],
2808                        max_peers,
2809                        features: Features::default(),
2810                        assignment_signer: Arc::new(TestSigner),
2811                    },
2812                )
2813                .await;
2814            if max_peers.is_some_and(|count| !(1..=50).contains(&count)) {
2815                assert!(result.err().unwrap().to_string().contains("maxPeers"));
2816            } else {
2817                result.unwrap().close().await;
2818            }
2819        }
2820    }
2821
2822    #[tokio::test]
2823    async fn anonymous_constructor_is_network_idle_and_rejects_authenticated_devices() {
2824        let control =
2825            ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2826                .expect("anonymous v2 control plane");
2827        assert_eq!(
2828            control.app_tag(),
2829            crate::app_tag_from_api_key(crate::test_constants::TEST_API_KEY),
2830        );
2831        let error = control
2832            .device_certificate("device", None, None, 0)
2833            .await
2834            .expect_err("anonymous clients do not own consumer authentication");
2835        assert!(error.to_string().contains("identity assertion provider"));
2836    }
2837
2838    #[tokio::test]
2839    async fn control_plane_retry_reuses_the_exact_body_and_idempotency_key() {
2840        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2841        let address = listener.local_addr().unwrap();
2842        let observed = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
2843        let server_observed = observed.clone();
2844        let server = tokio::spawn(async move {
2845            for attempt in 0..2 {
2846                let (mut stream, _) = listener.accept().await.unwrap();
2847                let mut bytes = Vec::new();
2848                let mut buffer = [0_u8; 4096];
2849                let header_end = loop {
2850                    let read = stream.read(&mut buffer).await.unwrap();
2851                    assert!(read > 0);
2852                    bytes.extend_from_slice(&buffer[..read]);
2853                    if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
2854                        break index + 4;
2855                    }
2856                };
2857                let headers = String::from_utf8_lossy(&bytes[..header_end]).to_string();
2858                let content_length = headers
2859                    .lines()
2860                    .find_map(|line| {
2861                        let (name, value) = line.split_once(':')?;
2862                        name.eq_ignore_ascii_case("content-length")
2863                            .then(|| value.trim().parse::<usize>().unwrap())
2864                    })
2865                    .unwrap_or(0);
2866                while bytes.len() < header_end + content_length {
2867                    let read = stream.read(&mut buffer).await.unwrap();
2868                    assert!(read > 0);
2869                    bytes.extend_from_slice(&buffer[..read]);
2870                }
2871                let request_id = headers
2872                    .lines()
2873                    .find_map(|line| {
2874                        let (name, value) = line.split_once(':')?;
2875                        name.eq_ignore_ascii_case("x-openrtc-idempotency-key")
2876                            .then(|| value.trim().to_string())
2877                    })
2878                    .expect("idempotency header");
2879                server_observed.lock().await.push((
2880                    request_id,
2881                    bytes[header_end..header_end + content_length].to_vec(),
2882                ));
2883
2884                let (status, body) = if attempt == 0 {
2885                    ("503 Service Unavailable", r#"{"error":"retry"}"#)
2886                } else {
2887                    ("200 OK", r#"{"ok":true}"#)
2888                };
2889                stream
2890                    .write_all(
2891                        format!(
2892                            "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
2893                            body.len(),
2894                        )
2895                        .as_bytes(),
2896                    )
2897                    .await
2898                    .unwrap();
2899            }
2900        });
2901
2902        let control =
2903            ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2904                .unwrap()
2905                .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
2906                .unwrap();
2907        let response: Value = control
2908            .post("/retry", json!({ "intent": "same" }))
2909            .await
2910            .unwrap();
2911        assert_eq!(response, json!({ "ok": true }));
2912        server.await.unwrap();
2913
2914        let observed = observed.lock().await;
2915        assert_eq!(observed.len(), 2);
2916        assert_eq!(observed[0], observed[1]);
2917    }
2918
2919    #[tokio::test]
2920    async fn native_identity_refreshes_once_only_after_unauthorized() {
2921        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2922        let address = listener.local_addr().unwrap();
2923        let server = tokio::spawn(async move {
2924            for attempt in 0..2 {
2925                let (mut stream, _) = listener.accept().await.unwrap();
2926                let mut bytes = Vec::new();
2927                let mut buffer = [0_u8; 4096];
2928                let header_end = loop {
2929                    let read = stream.read(&mut buffer).await.unwrap();
2930                    assert!(read > 0);
2931                    bytes.extend_from_slice(&buffer[..read]);
2932                    if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
2933                        break index + 4;
2934                    }
2935                };
2936                let headers = String::from_utf8_lossy(&bytes[..header_end]);
2937                let content_length = headers
2938                    .lines()
2939                    .find_map(|line| {
2940                        let (name, value) = line.split_once(':')?;
2941                        name.eq_ignore_ascii_case("content-length")
2942                            .then(|| value.trim().parse::<usize>().unwrap())
2943                    })
2944                    .unwrap_or(0);
2945                while bytes.len() < header_end + content_length {
2946                    let read = stream.read(&mut buffer).await.unwrap();
2947                    assert!(read > 0);
2948                    bytes.extend_from_slice(&buffer[..read]);
2949                }
2950                let request_body: Value =
2951                    serde_json::from_slice(&bytes[header_end..header_end + content_length])
2952                        .unwrap();
2953                let expected_assertion = if attempt == 0 { "stale" } else { "fresh" };
2954                assert_eq!(
2955                    request_body.get("assertion").and_then(Value::as_str),
2956                    Some(expected_assertion),
2957                );
2958                let (status, body) = if attempt == 0 {
2959                    ("401 Unauthorized", r#"{"error":"assertion rejected"}"#)
2960                } else {
2961                    (
2962                        "200 OK",
2963                        r#"{"identitySession":"identity","principalId":"principal","expiresAt":4102444800}"#,
2964                    )
2965                };
2966                stream
2967                    .write_all(
2968                        format!(
2969                            "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
2970                            body.len(),
2971                        )
2972                        .as_bytes(),
2973                    )
2974                    .await
2975                    .unwrap();
2976            }
2977        });
2978
2979        let provider = Arc::new(RecordingAssertionProvider::default());
2980        let control = ControlPlane::new(
2981            crate::test_constants::TEST_API_KEY,
2982            provider.clone(),
2983            Arc::new(TestSigner),
2984            Arc::new(InMemoryCertificate::default()),
2985        )
2986        .unwrap()
2987        .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
2988        .unwrap();
2989
2990        let identity = control
2991            .exchange_identity_after_rejection(false, "device-a")
2992            .await
2993            .unwrap();
2994        assert_eq!(identity.principal_id, "principal");
2995        assert_eq!(*provider.force_refreshes.lock().unwrap(), vec![false, true]);
2996        server.await.unwrap();
2997    }
2998
2999    #[tokio::test]
3000    async fn native_identity_exchange_is_coalesced_per_host_login_epoch_and_device() {
3001        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3002        let address = listener.local_addr().unwrap();
3003        let server = tokio::spawn(async move {
3004            for attempt in 0..2 {
3005                let (mut stream, _) = listener.accept().await.unwrap();
3006                let mut bytes = Vec::new();
3007                let mut buffer = [0_u8; 4096];
3008                let header_end = loop {
3009                    let read = stream.read(&mut buffer).await.unwrap();
3010                    assert!(read > 0);
3011                    bytes.extend_from_slice(&buffer[..read]);
3012                    if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
3013                        break index + 4;
3014                    }
3015                };
3016                let headers = String::from_utf8_lossy(&bytes[..header_end]);
3017                let content_length = headers
3018                    .lines()
3019                    .find_map(|line| {
3020                        let (name, value) = line.split_once(':')?;
3021                        name.eq_ignore_ascii_case("content-length")
3022                            .then(|| value.trim().parse::<usize>().unwrap())
3023                    })
3024                    .unwrap_or(0);
3025                while bytes.len() < header_end + content_length {
3026                    let read = stream.read(&mut buffer).await.unwrap();
3027                    assert!(read > 0);
3028                    bytes.extend_from_slice(&buffer[..read]);
3029                }
3030                let body = format!(
3031                    r#"{{"identitySession":"identity-{attempt}","principalId":"principal-{attempt}","expiresAt":4102444800}}"#,
3032                );
3033                stream
3034                    .write_all(
3035                        format!(
3036                            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
3037                            body.len(),
3038                        )
3039                        .as_bytes(),
3040                    )
3041                    .await
3042                    .unwrap();
3043            }
3044        });
3045
3046        let provider = Arc::new(RecordingAssertionProvider::default());
3047        let control = ControlPlane::new(
3048            crate::test_constants::TEST_API_KEY,
3049            provider.clone(),
3050            Arc::new(TestSigner),
3051            Arc::new(InMemoryCertificate::default()),
3052        )
3053        .unwrap()
3054        .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
3055        .unwrap();
3056
3057        let first = control
3058            .identity_for_epoch(0, "device-a", false)
3059            .await
3060            .unwrap();
3061        let reused = control
3062            .identity_for_epoch(0, "device-a", false)
3063            .await
3064            .unwrap();
3065        assert_eq!(first.response.principal_id, "principal-0");
3066        assert_eq!(reused.response.principal_id, "principal-0");
3067        assert_eq!(*provider.force_refreshes.lock().unwrap(), vec![false]);
3068
3069        // A second installation in the same signed-in epoch must obtain its
3070        // own consumer assertion. Reusing device-a's identity session would
3071        // bypass consumer-owned product device admission.
3072        let next = control
3073            .identity_for_epoch(0, "device-b", false)
3074            .await
3075            .unwrap();
3076        assert_eq!(next.response.principal_id, "principal-1");
3077        assert_eq!(
3078            *provider.force_refreshes.lock().unwrap(),
3079            vec![false, false]
3080        );
3081        assert_eq!(
3082            *provider.device_ids.lock().unwrap(),
3083            vec!["device-a".to_string(), "device-b".to_string()]
3084        );
3085        server.await.unwrap();
3086    }
3087
3088    #[tokio::test]
3089    async fn native_login_epoch_change_retires_the_old_capability_without_network_work() {
3090        let capabilities = NativeCapabilities::new(
3091            crate::test_constants::TEST_API_KEY,
3092            "device",
3093            "native",
3094            Arc::new(NeverGrantProvider),
3095        )
3096        .unwrap();
3097        let handle = capabilities.devices("principal").unwrap();
3098        let relay = CredentialRelay::default();
3099        relay.set(Some("device-certificate".to_string())).unwrap();
3100        let credential = relay.provider();
3101        let (epoch_sender, epoch_receiver) = watch::channel(7_u64);
3102        let monitor = spawn_identity_epoch_monitor(epoch_receiver, 7, handle.closer(), relay);
3103
3104        epoch_sender.send(8).unwrap();
3105        tokio::time::timeout(std::time::Duration::from_secs(1), monitor)
3106            .await
3107            .expect("epoch retirement must be prompt")
3108            .unwrap();
3109        assert!(handle.is_closed());
3110        assert_eq!(credential(), None);
3111    }
3112
3113    #[test]
3114    fn native_space_namespace_matches_browser_derivation() {
3115        let api_key = crate::test_constants::TEST_API_KEY;
3116        let requested = "portfolio-cursors";
3117        let expected = hex::encode(<sha2::Sha256 as sha2::Digest>::digest(
3118            format!("{api_key}:{requested}").as_bytes(),
3119        ));
3120        assert_eq!(
3121            native_capability_avenue_id(api_key, AnonymousKind::Space, requested),
3122            expected,
3123        );
3124        assert_eq!(
3125            native_capability_avenue_id(api_key, AnonymousKind::Room, "match-123"),
3126            "match-123",
3127        );
3128    }
3129
3130    #[tokio::test]
3131    async fn signaling_slot_installs_once_and_fails_closed_before_activation() {
3132        let slot = SignalingSlot::default();
3133        assert!(slot.search_devices("principal", None).await.is_err());
3134        slot.install(Arc::new(crate::signaling::GatewayRequiredSignalingBackend))
3135            .await
3136            .expect("first capability installs");
3137        assert!(slot
3138            .install(Arc::new(crate::signaling::GatewayRequiredSignalingBackend,))
3139            .await
3140            .is_err());
3141    }
3142}