Skip to main content

openrtc/
native_protocol.rs

1use base64::Engine as _;
2use serde::{Deserialize, Serialize};
3
4fn parse_application_key_agreement_public_key(
5    json: &serde_json::Value,
6) -> Option<[u8; crate::key_agreement::PUBLIC_KEY_BYTES]> {
7    let payload = json.get("applicationKeyAgreement")?.as_object()?;
8    if payload.get("algorithm").and_then(|value| value.as_str())
9        != Some(crate::key_agreement::KEY_ALGORITHM)
10    {
11        return None;
12    }
13    let public_key = payload.get("publicKey")?.as_str()?.trim();
14    if public_key.is_empty() {
15        return None;
16    }
17    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
18        .decode(public_key)
19        .ok()?;
20    if bytes.len() != crate::key_agreement::PUBLIC_KEY_BYTES {
21        return None;
22    }
23    let mut out = [0u8; crate::key_agreement::PUBLIC_KEY_BYTES];
24    out.copy_from_slice(&bytes);
25    Some(out)
26}
27
28fn parse_transport_trust_device_id(json: &serde_json::Value) -> Option<String> {
29    json.get("transportTrust")
30        .and_then(|value| value.as_object())
31        .and_then(|payload| payload.get("deviceId"))
32        .and_then(|value| value.as_str())
33        .map(str::trim)
34        .filter(|value| !value.is_empty())
35        .map(ToOwned::to_owned)
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum NativeMessageAction {
41    Request,
42    Response,
43    Event,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct NativeMainMessage {
49    pub channel: String,
50    pub action: NativeMessageAction,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub request_id: Option<String>,
53    pub payload: serde_json::Value,
54    pub timestamp: i64,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub from: Option<String>,
57}
58
59/// Channel name used for SDK-owned session token presentation.
60/// The connecting client sends this as the first native message after
61/// connection when a compound ticket contained an embedded token.
62pub const SESSION_TOKEN_CHANNEL: &str = "session-token";
63pub const PEER_DATA_CHANNEL: &str = "peer-data";
64pub const OFFLINE_PROOF_CHANNEL: &str = "offline-device-proof";
65/// `send_peer` is the bounded, ordered message surface. Larger payloads use a
66/// named stream or a protocol package so lifecycle/control frames cannot be
67/// stalled behind bulk transfer data on the native-main stream.
68pub const MAX_PEER_MESSAGE_BYTES: usize = 64 * 1024;
69
70/// Lifetime contract for the stream carrying a session-token presentation.
71///
72/// This describes the stream, not the sender runtime or grant scope. Browser
73/// bridges commonly use a bounded request/response exchange, while native
74/// peers retain the admitted stream as their generation-bound control route.
75#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "kebab-case")]
77pub enum TokenStreamContract {
78    #[default]
79    OneShotAdmission,
80    PersistentControl,
81}
82
83/// Reverse-direction admission carried in the response to a typed reciprocal
84/// request. Keeping it on the request's stream binds both proofs to the same
85/// physical generation and avoids selecting a stale endpoint connection after
86/// network replacement.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "camelCase")]
89pub struct ReciprocalSessionTokenPresentation {
90    pub presentation_id: String,
91    pub token: String,
92    pub token_payload: String,
93    pub device_id: String,
94    pub stream_contract: TokenStreamContract,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[cfg(not(target_arch = "wasm32"))]
99pub(crate) enum ReciprocalSessionTokenPresentationState {
100    Absent,
101    Malformed,
102    Present(ReciprocalSessionTokenPresentation),
103}
104
105impl NativeMainMessage {
106    pub fn is_handshake(&self) -> bool {
107        self.channel == "handshake"
108    }
109
110    pub fn is_session_token_presentation(&self) -> bool {
111        self.channel == SESSION_TOKEN_CHANNEL && matches!(self.action, NativeMessageAction::Request)
112    }
113
114    pub fn is_session_token_response(&self) -> bool {
115        self.channel == SESSION_TOKEN_CHANNEL
116            && matches!(self.action, NativeMessageAction::Response)
117    }
118
119    pub fn is_session_token_response_ack(&self, connection_id: &str) -> bool {
120        self.channel == SESSION_TOKEN_CHANNEL
121            && matches!(self.action, NativeMessageAction::Event)
122            && self
123                .payload
124                .get("responseAck")
125                .and_then(|value| value.as_bool())
126                == Some(true)
127            && self
128                .payload
129                .get("connectionId")
130                .and_then(|value| value.as_str())
131                == Some(connection_id)
132    }
133
134    pub fn is_peer_data(&self) -> bool {
135        self.channel == PEER_DATA_CHANNEL
136    }
137
138    #[cfg(not(target_arch = "wasm32"))]
139    pub(crate) fn is_offline_proof_request(&self) -> bool {
140        self.channel == OFFLINE_PROOF_CHANNEL && matches!(self.action, NativeMessageAction::Request)
141    }
142
143    #[cfg(not(target_arch = "wasm32"))]
144    pub(crate) fn is_offline_proof_response(&self) -> bool {
145        self.channel == OFFLINE_PROOF_CHANNEL
146            && matches!(self.action, NativeMessageAction::Response)
147    }
148
149    #[cfg(not(target_arch = "wasm32"))]
150    pub(crate) fn offline_proof_challenge(&self) -> Option<crate::offline::OfflineProofChallenge> {
151        serde_json::from_value(self.payload.get("challenge")?.clone()).ok()
152    }
153
154    #[cfg(not(target_arch = "wasm32"))]
155    pub(crate) fn offline_connection_proof(
156        &self,
157    ) -> Option<crate::offline::OfflineConnectionProof> {
158        serde_json::from_value(self.payload.get("proof")?.clone()).ok()
159    }
160
161    #[cfg(not(target_arch = "wasm32"))]
162    pub(crate) fn offline_proof_request(challenge: &crate::offline::OfflineProofChallenge) -> Self {
163        Self {
164            channel: OFFLINE_PROOF_CHANNEL.to_string(),
165            action: NativeMessageAction::Request,
166            request_id: Some(challenge.request_id.clone()),
167            payload: serde_json::json!({ "challenge": challenge }),
168            timestamp: crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64,
169            from: None,
170        }
171    }
172
173    #[cfg(not(target_arch = "wasm32"))]
174    pub(crate) fn offline_proof_response(
175        request_id: &str,
176        proof: &crate::offline::OfflineConnectionProof,
177    ) -> Self {
178        Self {
179            channel: OFFLINE_PROOF_CHANNEL.to_string(),
180            action: NativeMessageAction::Response,
181            request_id: Some(request_id.to_string()),
182            payload: serde_json::json!({ "proof": proof }),
183            timestamp: crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64,
184            from: None,
185        }
186    }
187
188    pub fn peer_data_payload(&self) -> Option<Vec<u8>> {
189        let payload_base64 = self
190            .payload
191            .get("payloadBase64")
192            .and_then(|value| value.as_str())
193            .map(str::trim)
194            .filter(|value| !value.is_empty())?;
195        base64::engine::general_purpose::STANDARD
196            .decode(payload_base64.as_bytes())
197            .ok()
198    }
199
200    pub fn claimed_device_id(&self) -> Option<String> {
201        self.payload
202            .get("deviceId")
203            .and_then(|value| value.as_str())
204            .map(str::trim)
205            .filter(|value| !value.is_empty())
206            .map(ToOwned::to_owned)
207    }
208
209    pub fn presented_session_token(&self) -> Option<String> {
210        self.payload
211            .get("token")
212            .and_then(|value| value.as_str())
213            .map(str::trim)
214            .filter(|value| !value.is_empty())
215            .map(ToOwned::to_owned)
216    }
217
218    pub fn presented_session_token_payload(&self) -> Option<String> {
219        self.payload
220            .get("tokenPayload")
221            .and_then(|value| value.as_str())
222            .map(str::trim)
223            .filter(|value| !value.is_empty())
224            .map(ToOwned::to_owned)
225    }
226
227    pub fn session_token_stream_contract(&self) -> TokenStreamContract {
228        self.payload
229            .get("streamContract")
230            .cloned()
231            .and_then(|value| serde_json::from_value(value).ok())
232            .unwrap_or_default()
233    }
234
235    /// Whether the presenter has lost the reciprocal inbound proof for this
236    /// physical generation and is asking the peer's admission actor to
237    /// re-present its own token. Missing on older peers means `false`.
238    #[cfg(not(target_arch = "wasm32"))]
239    pub(crate) fn session_token_reciprocal_requested(&self) -> bool {
240        self.payload
241            .get("reciprocalRequested")
242            .and_then(|value| value.as_bool())
243            .unwrap_or(false)
244    }
245
246    pub fn reciprocal_session_token_mode(&self) -> Option<&str> {
247        self.payload
248            .get("reciprocalMode")
249            .and_then(|value| value.as_str())
250    }
251
252    #[cfg(not(target_arch = "wasm32"))]
253    pub(crate) fn reciprocal_session_token_presentation_state(
254        &self,
255    ) -> ReciprocalSessionTokenPresentationState {
256        let Some(presentation) = self.payload.get("reciprocalPresentation").cloned() else {
257            return ReciprocalSessionTokenPresentationState::Absent;
258        };
259        let Ok(parsed) = serde_json::from_value::<ReciprocalSessionTokenPresentation>(presentation)
260        else {
261            return ReciprocalSessionTokenPresentationState::Malformed;
262        };
263        if parsed.presentation_id.trim().is_empty()
264            || parsed.token.trim().is_empty()
265            || parsed.token_payload.trim().is_empty()
266            || parsed.device_id.trim().is_empty()
267        {
268            return ReciprocalSessionTokenPresentationState::Malformed;
269        }
270        ReciprocalSessionTokenPresentationState::Present(parsed)
271    }
272
273    #[cfg(not(target_arch = "wasm32"))]
274    pub(crate) fn reciprocal_session_token_presentation_id(&self) -> Option<String> {
275        self.payload
276            .get("reciprocalPresentation")
277            .and_then(|value| value.get("presentationId"))
278            .and_then(|value| value.as_str())
279            .map(str::trim)
280            .filter(|value| !value.is_empty())
281            .map(ToOwned::to_owned)
282    }
283
284    /// New native peers opt into an explicit approval-delivery ACK before a
285    /// persistent control stream participates in canonical stream arbitration.
286    pub(crate) fn session_token_response_ack_requested(&self) -> bool {
287        self.payload
288            .get("responseAckRequested")
289            .and_then(|value| value.as_bool())
290            .unwrap_or(false)
291    }
292
293    pub fn with_session_token_stream_contract(mut self, contract: TokenStreamContract) -> Self {
294        if let Some(payload) = self.payload.as_object_mut() {
295            payload.insert(
296                "streamContract".to_string(),
297                serde_json::to_value(contract)
298                    .expect("session-token stream contract must serialize"),
299            );
300        }
301        self
302    }
303
304    pub(crate) fn with_session_token_reciprocal_requested(mut self, requested: bool) -> Self {
305        if requested {
306            if let Some(payload) = self.payload.as_object_mut() {
307                payload.insert(
308                    "reciprocalRequested".to_string(),
309                    serde_json::Value::Bool(true),
310                );
311                payload.insert(
312                    "reciprocalMode".to_string(),
313                    serde_json::Value::String("inline-v1".to_string()),
314                );
315            }
316        }
317        self
318    }
319
320    pub fn session_token_approved(&self) -> Option<bool> {
321        self.payload
322            .get("approved")
323            .and_then(|value| value.as_bool())
324    }
325
326    pub fn approved_session_scope(&self) -> Option<String> {
327        self.payload
328            .get("scope")
329            .and_then(|value| value.as_str())
330            .map(str::trim)
331            .filter(|value| !value.is_empty())
332            .map(ToOwned::to_owned)
333    }
334
335    pub fn session_token_error(&self) -> Option<String> {
336        self.payload
337            .get("reason")
338            .and_then(|value| value.as_str())
339            .map(str::trim)
340            .filter(|value| !value.is_empty())
341            .map(ToOwned::to_owned)
342    }
343
344    /// Build a session-token presentation message for the connecting client
345    /// to send to the host.
346    pub fn session_token_presentation(token: &str) -> Self {
347        Self::session_token_presentation_with_payload(token, None)
348    }
349
350    pub(crate) fn session_token_presentation_with_payload(
351        token: &str,
352        token_payload: Option<&str>,
353    ) -> Self {
354        Self::session_token_presentation_with_payload_and_device_id(token, token_payload, None)
355    }
356
357    pub(crate) fn session_token_presentation_with_payload_and_device_id(
358        token: &str,
359        token_payload: Option<&str>,
360        device_id: Option<&str>,
361    ) -> Self {
362        #[cfg(target_arch = "wasm32")]
363        let timestamp = js_sys::Date::now() as i64;
364        #[cfg(not(target_arch = "wasm32"))]
365        let timestamp = std::time::SystemTime::now()
366            .duration_since(std::time::UNIX_EPOCH)
367            .map(|d| d.as_millis() as i64)
368            .unwrap_or(0);
369
370        let mut payload = serde_json::json!({
371            "token": token,
372            "streamContract": TokenStreamContract::OneShotAdmission,
373            "responseAckRequested": true,
374        });
375        if let Some(token_payload) = token_payload {
376            if let Some(object) = payload.as_object_mut() {
377                object.insert(
378                    "tokenPayload".to_string(),
379                    serde_json::Value::String(token_payload.to_string()),
380                );
381            }
382        }
383        if let Some(device_id) = device_id.map(str::trim).filter(|value| !value.is_empty()) {
384            if let Some(object) = payload.as_object_mut() {
385                object.insert(
386                    "deviceId".to_string(),
387                    serde_json::Value::String(device_id.to_string()),
388                );
389            }
390        }
391
392        Self {
393            channel: SESSION_TOKEN_CHANNEL.to_string(),
394            action: NativeMessageAction::Request,
395            request_id: None,
396            payload,
397            timestamp,
398            from: None,
399        }
400    }
401
402    pub fn session_token_approval(scope: Option<&str>, connection_id: &str) -> Self {
403        #[cfg(target_arch = "wasm32")]
404        let timestamp = js_sys::Date::now() as i64;
405        #[cfg(not(target_arch = "wasm32"))]
406        let timestamp = std::time::SystemTime::now()
407            .duration_since(std::time::UNIX_EPOCH)
408            .map(|d| d.as_millis() as i64)
409            .unwrap_or(0);
410
411        Self {
412            channel: SESSION_TOKEN_CHANNEL.to_string(),
413            action: NativeMessageAction::Response,
414            request_id: None,
415            payload: serde_json::json!({
416                "approved": true,
417                "scope": scope,
418                "connectionId": connection_id,
419            }),
420            timestamp,
421            from: None,
422        }
423    }
424
425    #[cfg(not(target_arch = "wasm32"))]
426    pub(crate) fn with_reciprocal_session_token_presentation(
427        mut self,
428        presentation: ReciprocalSessionTokenPresentation,
429    ) -> Self {
430        if let Some(payload) = self.payload.as_object_mut() {
431            payload.insert(
432                "reciprocalMode".to_string(),
433                serde_json::Value::String("inline-v1".to_string()),
434            );
435            payload.insert(
436                "reciprocalPresentation".to_string(),
437                serde_json::to_value(presentation)
438                    .expect("reciprocal session-token presentation must serialize"),
439            );
440        }
441        self
442    }
443
444    pub fn session_token_rejection(reason: &str, connection_id: &str) -> Self {
445        #[cfg(target_arch = "wasm32")]
446        let timestamp = js_sys::Date::now() as i64;
447        #[cfg(not(target_arch = "wasm32"))]
448        let timestamp = std::time::SystemTime::now()
449            .duration_since(std::time::UNIX_EPOCH)
450            .map(|d| d.as_millis() as i64)
451            .unwrap_or(0);
452
453        Self {
454            channel: SESSION_TOKEN_CHANNEL.to_string(),
455            action: NativeMessageAction::Response,
456            request_id: None,
457            payload: serde_json::json!({
458                "approved": false,
459                "reason": reason,
460                "connectionId": connection_id,
461            }),
462            timestamp,
463            from: None,
464        }
465    }
466
467    pub fn session_token_response_ack(connection_id: &str) -> Self {
468        #[cfg(target_arch = "wasm32")]
469        let timestamp = js_sys::Date::now() as i64;
470        #[cfg(not(target_arch = "wasm32"))]
471        let timestamp = std::time::SystemTime::now()
472            .duration_since(std::time::UNIX_EPOCH)
473            .map(|d| d.as_millis() as i64)
474            .unwrap_or(0);
475
476        Self {
477            channel: SESSION_TOKEN_CHANNEL.to_string(),
478            action: NativeMessageAction::Event,
479            request_id: None,
480            payload: serde_json::json!({
481                "responseAck": true,
482                "connectionId": connection_id,
483            }),
484            timestamp,
485            from: None,
486        }
487    }
488
489    pub fn with_reciprocal_session_token_ack(
490        mut self,
491        presentation_id: &str,
492        accepted: bool,
493        scope: Option<&str>,
494        reason: Option<&str>,
495    ) -> Self {
496        if let Some(payload) = self.payload.as_object_mut() {
497            payload.insert(
498                "reciprocalPresentationId".to_string(),
499                serde_json::Value::String(presentation_id.to_string()),
500            );
501            payload.insert(
502                "reciprocalAccepted".to_string(),
503                serde_json::Value::Bool(accepted),
504            );
505            if let Some(scope) = scope {
506                payload.insert(
507                    "reciprocalScope".to_string(),
508                    serde_json::Value::String(scope.to_string()),
509                );
510            }
511            if let Some(reason) = reason {
512                payload.insert(
513                    "reciprocalReason".to_string(),
514                    serde_json::Value::String(reason.to_string()),
515                );
516            }
517        }
518        self
519    }
520
521    #[cfg(not(target_arch = "wasm32"))]
522    pub(crate) fn reciprocal_session_token_ack_presentation_id(&self) -> Option<String> {
523        self.payload
524            .get("reciprocalPresentationId")
525            .and_then(|value| value.as_str())
526            .map(str::trim)
527            .filter(|value| !value.is_empty())
528            .map(ToOwned::to_owned)
529    }
530
531    #[cfg(not(target_arch = "wasm32"))]
532    pub(crate) fn reciprocal_session_token_ack_accepted(&self) -> Option<bool> {
533        self.payload
534            .get("reciprocalAccepted")
535            .and_then(|value| value.as_bool())
536    }
537
538    pub fn reciprocal_session_token_ack_scope(&self) -> Option<String> {
539        self.payload
540            .get("reciprocalScope")
541            .and_then(|value| value.as_str())
542            .map(str::trim)
543            .filter(|value| !value.is_empty())
544            .map(ToOwned::to_owned)
545    }
546
547    pub fn peer_data(payload: &[u8]) -> Self {
548        #[cfg(target_arch = "wasm32")]
549        let timestamp = js_sys::Date::now() as i64;
550        #[cfg(not(target_arch = "wasm32"))]
551        let timestamp = std::time::SystemTime::now()
552            .duration_since(std::time::UNIX_EPOCH)
553            .map(|d| d.as_millis() as i64)
554            .unwrap_or(0);
555
556        Self {
557            channel: PEER_DATA_CHANNEL.to_string(),
558            action: NativeMessageAction::Event,
559            request_id: None,
560            payload: serde_json::json!({
561                "payloadBase64": base64::engine::general_purpose::STANDARD.encode(payload),
562            }),
563            timestamp,
564            from: None,
565        }
566    }
567}
568
569#[cfg(not(target_arch = "wasm32"))]
570pub(crate) fn encode_native_main_payload(message: &NativeMainMessage) -> Result<Vec<u8>, String> {
571    let payload = serde_json::to_vec(message)
572        .map_err(|error| format!("encode native-main message: {error}"))?;
573    let len = u32::try_from(payload.len())
574        .map_err(|_| "native-main message exceeds the frame bound".to_string())?;
575    let mut frame = Vec::with_capacity(4 + payload.len());
576    frame.extend_from_slice(&len.to_be_bytes());
577    frame.extend_from_slice(&payload);
578    Ok(frame)
579}
580
581#[cfg(not(target_arch = "wasm32"))]
582pub(crate) fn encode_native_main_opening(message: &NativeMainMessage) -> Result<Vec<u8>, String> {
583    let payload = encode_native_main_payload(message)?;
584    let label = b"main";
585    let mut opening = Vec::with_capacity(1 + 4 + label.len() + payload.len());
586    opening.push(0);
587    opening.extend_from_slice(&(label.len() as u32).to_be_bytes());
588    opening.extend_from_slice(label);
589    opening.extend_from_slice(&payload);
590    Ok(opening)
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct HandshakeFeatures {
595    pub carrier_schema: Option<u8>,
596    pub webrtc: Option<bool>,
597    pub moq: Option<bool>,
598    pub dedicated_carrier_control: Option<bool>,
599    pub dedicated_carrier_control_ready: Option<bool>,
600    pub ble: Option<bool>,
601    pub application_key_agreement: Option<bool>,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct TypeScriptHandshake {
606    pub action: Option<String>,
607    pub session_token: Option<String>,
608    pub session_token_payload: Option<String>,
609    pub claimed_device_id: Option<String>,
610    pub capabilities: Option<HandshakeFeatures>,
611    pub application_key_agreement_public_key: Option<[u8; crate::key_agreement::PUBLIC_KEY_BYTES]>,
612}
613
614#[derive(Debug, Clone)]
615pub enum ParsedMainFrame {
616    NativeMessage(NativeMainMessage),
617    TypeScriptHandshake(TypeScriptHandshake),
618    TypeScriptJson(serde_json::Value),
619    Opaque,
620}
621
622#[derive(Debug, Clone)]
623pub enum InspectedMainFrame {
624    NativeMessage {
625        message: NativeMainMessage,
626        handshake: Option<HandshakeBinding>,
627    },
628    /// SDK-owned response for a session-token presentation received on an
629    /// application-forwarded one-shot main stream. The host adapter writes
630    /// this response verbatim, then asks the client to run post-admission
631    /// lifecycle work only after the wire flush completes.
632    SessionTokenResponse {
633        response: NativeMainMessage,
634        accepted: bool,
635        is_first_presentation: bool,
636    },
637    ForwardOpaque,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
641#[serde(rename_all = "camelCase")]
642pub struct HandshakeBinding {
643    pub known_device_id: Option<String>,
644    pub claimed_device_id: Option<String>,
645    pub admitted_device_id: Option<String>,
646    pub authoritative_device_id_hint: Option<String>,
647}
648
649pub fn parse_main_frame(frame: &[u8]) -> ParsedMainFrame {
650    if let Ok(message) = serde_json::from_slice::<NativeMainMessage>(frame) {
651        return ParsedMainFrame::NativeMessage(message);
652    }
653
654    if frame.len() >= 2 && frame[0] == 0x00 {
655        if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&frame[1..]) {
656            if json.get("type").and_then(|value| value.as_str()) == Some("handshake") {
657                return ParsedMainFrame::TypeScriptHandshake(TypeScriptHandshake {
658                    action: json
659                        .get("action")
660                        .and_then(|value| value.as_str())
661                        .map(ToOwned::to_owned),
662                    session_token: json
663                        .get("sessionToken")
664                        .and_then(|value| value.as_str())
665                        .map(str::trim)
666                        .filter(|value| !value.is_empty())
667                        .map(ToOwned::to_owned),
668                    session_token_payload: json
669                        .get("sessionTokenPayload")
670                        .and_then(|value| value.as_str())
671                        .map(str::trim)
672                        .filter(|value| !value.is_empty())
673                        .map(ToOwned::to_owned),
674                    claimed_device_id: parse_transport_trust_device_id(&json),
675                    capabilities: json
676                        .get("capabilities")
677                        .and_then(|value| value.as_object())
678                        .map(|value| HandshakeFeatures {
679                            carrier_schema: value
680                                .get("carrierSchema")
681                                .and_then(|version| version.as_u64())
682                                .and_then(|version| u8::try_from(version).ok()),
683                            webrtc: value.get("webrtc").and_then(|flag| flag.as_bool()),
684                            moq: value.get("moq").and_then(|flag| flag.as_bool()),
685                            dedicated_carrier_control: value
686                                .get("dedicatedCarrierControl")
687                                .and_then(|flag| flag.as_bool()),
688                            dedicated_carrier_control_ready: value
689                                .get("dedicatedCarrierControlReady")
690                                .and_then(|flag| flag.as_bool()),
691                            ble: value.get("ble").and_then(|flag| flag.as_bool()),
692                            application_key_agreement: value
693                                .get("applicationKeyAgreement")
694                                .and_then(|flag| flag.as_bool()),
695                        }),
696                    application_key_agreement_public_key:
697                        parse_application_key_agreement_public_key(&json),
698                });
699            }
700            return ParsedMainFrame::TypeScriptJson(json);
701        }
702    }
703
704    ParsedMainFrame::Opaque
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn parses_native_handshake_message() {
713        let frame = serde_json::json!({
714            "channel": "handshake",
715            "action": "request",
716            "payload": { "deviceId": "device-1" },
717            "timestamp": 123,
718        });
719
720        match parse_main_frame(frame.to_string().as_bytes()) {
721            ParsedMainFrame::NativeMessage(message) => {
722                assert!(message.is_handshake());
723                assert_eq!(message.claimed_device_id().as_deref(), Some("device-1"));
724            }
725            other => panic!("expected native message, got {:?}", other),
726        }
727    }
728
729    #[test]
730    fn parses_typescript_handshake_message() {
731        let json = serde_json::json!({
732            "type": "handshake",
733            "action": "hello",
734            "sessionToken": "token-1",
735            "sessionTokenPayload": "payload-1",
736            "transportTrust": {
737                "version": 1,
738                "deviceId": "device-1",
739                "identityFingerprint": "fingerprint-1"
740            },
741            "capabilities": {
742                "carrierSchema": 2,
743                "webrtc": true,
744                "moq": false,
745                "dedicatedCarrierControl": true,
746                "dedicatedCarrierControlReady": false,
747                "ble": true,
748            },
749        });
750        let mut frame = vec![0x00];
751        frame.extend_from_slice(json.to_string().as_bytes());
752
753        match parse_main_frame(&frame) {
754            ParsedMainFrame::TypeScriptHandshake(handshake) => {
755                assert_eq!(handshake.action.as_deref(), Some("hello"));
756                assert_eq!(handshake.session_token.as_deref(), Some("token-1"));
757                assert_eq!(
758                    handshake.session_token_payload.as_deref(),
759                    Some("payload-1")
760                );
761                assert_eq!(handshake.claimed_device_id.as_deref(), Some("device-1"));
762                let capabilities = handshake.capabilities.expect("capabilities");
763                assert_eq!(capabilities.carrier_schema, Some(2));
764                assert_eq!(capabilities.webrtc, Some(true));
765                assert_eq!(capabilities.moq, Some(false));
766                assert_eq!(capabilities.dedicated_carrier_control, Some(true));
767                assert_eq!(capabilities.dedicated_carrier_control_ready, Some(false));
768                assert_eq!(capabilities.ble, Some(true));
769            }
770            other => panic!("expected typescript handshake, got {:?}", other),
771        }
772    }
773
774    #[test]
775    fn falls_back_to_opaque_for_unrecognized_bytes() {
776        assert!(matches!(
777            parse_main_frame(b"\x01\x02\x03"),
778            ParsedMainFrame::Opaque
779        ));
780    }
781
782    #[test]
783    fn parses_peer_data_native_message() {
784        let message = NativeMainMessage::peer_data(b"hello peer");
785        let frame = serde_json::to_vec(&message).expect("peer data should serialize");
786
787        match parse_main_frame(&frame) {
788            ParsedMainFrame::NativeMessage(parsed) => {
789                assert!(parsed.is_peer_data());
790                assert_eq!(
791                    parsed.peer_data_payload().as_deref(),
792                    Some(b"hello peer".as_slice())
793                );
794            }
795            other => panic!("expected native peer data message, got {:?}", other),
796        }
797    }
798
799    #[test]
800    fn session_token_stream_contract_round_trips_and_legacy_defaults_to_one_shot() {
801        let persistent = NativeMainMessage::session_token_presentation("token")
802            .with_session_token_stream_contract(TokenStreamContract::PersistentControl);
803        let encoded = serde_json::to_vec(&persistent).expect("serialize persistent contract");
804        let decoded: NativeMainMessage =
805            serde_json::from_slice(&encoded).expect("deserialize persistent contract");
806        assert_eq!(
807            decoded.session_token_stream_contract(),
808            TokenStreamContract::PersistentControl,
809        );
810
811        let mut legacy = NativeMainMessage::session_token_presentation("legacy-token");
812        legacy
813            .payload
814            .as_object_mut()
815            .expect("presentation payload")
816            .remove("streamContract");
817        assert_eq!(
818            legacy.session_token_stream_contract(),
819            TokenStreamContract::OneShotAdmission,
820        );
821        assert!(!legacy.session_token_reciprocal_requested());
822    }
823
824    #[test]
825    fn session_token_reciprocal_request_round_trips_and_defaults_false() {
826        let legacy = NativeMainMessage::session_token_presentation("legacy-token");
827        assert!(!legacy.session_token_reciprocal_requested());
828        assert_eq!(legacy.reciprocal_session_token_mode(), None);
829
830        let requested = NativeMainMessage::session_token_presentation("token")
831            .with_session_token_reciprocal_requested(true);
832        let encoded = serde_json::to_vec(&requested).expect("serialize reciprocal request");
833        let decoded: NativeMainMessage =
834            serde_json::from_slice(&encoded).expect("deserialize reciprocal request");
835        assert!(decoded.session_token_reciprocal_requested());
836        assert_eq!(decoded.reciprocal_session_token_mode(), Some("inline-v1"));
837    }
838
839    #[test]
840    fn reciprocal_session_token_presentation_round_trips_on_approval() {
841        let response = NativeMainMessage::session_token_approval(Some("user-device"), "conn-1")
842            .with_reciprocal_session_token_presentation(ReciprocalSessionTokenPresentation {
843                presentation_id: "presentation-1".to_string(),
844                token: "reverse-token".to_string(),
845                token_payload: "reverse-payload".to_string(),
846                device_id: "device-1".to_string(),
847                stream_contract: TokenStreamContract::PersistentControl,
848            });
849        let encoded = serde_json::to_vec(&response).expect("serialize reciprocal response");
850        let decoded: NativeMainMessage =
851            serde_json::from_slice(&encoded).expect("deserialize reciprocal response");
852        assert_eq!(decoded.reciprocal_session_token_mode(), Some("inline-v1"));
853        assert_eq!(
854            decoded.reciprocal_session_token_presentation_state(),
855            ReciprocalSessionTokenPresentationState::Present(ReciprocalSessionTokenPresentation {
856                presentation_id: "presentation-1".to_string(),
857                token: "reverse-token".to_string(),
858                token_payload: "reverse-payload".to_string(),
859                device_id: "device-1".to_string(),
860                stream_contract: TokenStreamContract::PersistentControl,
861            })
862        );
863
864        assert_eq!(
865            NativeMainMessage::session_token_approval(None, "conn-1")
866                .reciprocal_session_token_presentation_state(),
867            ReciprocalSessionTokenPresentationState::Absent,
868        );
869
870        let malformed = NativeMainMessage::session_token_approval(None, "conn-1")
871            .with_reciprocal_session_token_presentation(ReciprocalSessionTokenPresentation {
872                presentation_id: "presentation-2".to_string(),
873                token: "".to_string(),
874                token_payload: "reverse-payload".to_string(),
875                device_id: "device-1".to_string(),
876                stream_contract: TokenStreamContract::OneShotAdmission,
877            });
878        assert_eq!(
879            malformed.reciprocal_session_token_presentation_state(),
880            ReciprocalSessionTokenPresentationState::Malformed,
881        );
882    }
883
884    #[test]
885    fn parses_pluto_signal_envelope_with_ts_prefix() {
886        let json = serde_json::json!({
887            "type": "#pluto-signal",
888            "content": {
889                "transport": "webrtc",
890                "type": "sdp",
891                "sdp": { "type": "answer", "sdp": "v=0\r\n" }
892            }
893        });
894        let mut frame = vec![0x00];
895        frame.extend_from_slice(json.to_string().as_bytes());
896
897        match parse_main_frame(&frame) {
898            ParsedMainFrame::TypeScriptJson(parsed) => {
899                assert_eq!(
900                    parsed.get("type").and_then(|v| v.as_str()),
901                    Some("#pluto-signal")
902                );
903                let content = parsed.get("content").unwrap();
904                assert_eq!(
905                    content.get("transport").and_then(|v| v.as_str()),
906                    Some("webrtc")
907                );
908            }
909            other => panic!("expected TypeScriptJson for #pluto-signal, got {:?}", other),
910        }
911    }
912
913    #[test]
914    fn raw_pluto_signal_without_prefix_is_opaque() {
915        let json = serde_json::json!({
916            "type": "#pluto-signal",
917            "content": { "transport": "webrtc", "type": "sdp" }
918        });
919        let frame = json.to_string().into_bytes();
920
921        match parse_main_frame(&frame) {
922            ParsedMainFrame::NativeMessage(_) => {
923                panic!("raw #pluto-signal should not parse as NativeMainMessage")
924            }
925            ParsedMainFrame::TypeScriptJson(_) => {
926                panic!("raw #pluto-signal without 0x00 prefix should not be TypeScriptJson")
927            }
928            ParsedMainFrame::Opaque => {}
929            other => panic!("expected Opaque, got {:?}", other),
930        }
931    }
932
933    #[test]
934    fn session_token_response_helpers_round_trip() {
935        let presentation = NativeMainMessage::session_token_presentation_with_payload(
936            "token-1",
937            Some("payload-1"),
938        );
939        assert!(presentation.is_session_token_presentation());
940        assert_eq!(
941            presentation.presented_session_token().as_deref(),
942            Some("token-1")
943        );
944        assert_eq!(
945            presentation.presented_session_token_payload().as_deref(),
946            Some("payload-1")
947        );
948        assert!(presentation.session_token_response_ack_requested());
949
950        let presentation_with_device =
951            NativeMainMessage::session_token_presentation_with_payload_and_device_id(
952                "token-1",
953                Some("payload-1"),
954                Some("device-1"),
955            );
956        assert_eq!(
957            presentation_with_device.claimed_device_id().as_deref(),
958            Some("device-1")
959        );
960
961        let approved = NativeMainMessage::session_token_approval(Some("magic-link"), "conn-1");
962        assert!(approved.is_session_token_response());
963        assert_eq!(approved.session_token_approved(), Some(true));
964        assert_eq!(
965            approved.approved_session_scope().as_deref(),
966            Some("magic-link")
967        );
968
969        let rejected = NativeMainMessage::session_token_rejection("invalid-token", "conn-1");
970        assert!(rejected.is_session_token_response());
971        assert_eq!(rejected.session_token_approved(), Some(false));
972        assert_eq!(
973            rejected.session_token_error().as_deref(),
974            Some("invalid-token")
975        );
976
977        let ack = NativeMainMessage::session_token_response_ack("conn-1");
978        assert!(!ack.is_session_token_presentation());
979        assert!(ack.is_session_token_response_ack("conn-1"));
980        assert!(!ack.is_session_token_response_ack("conn-2"));
981
982        let reciprocal_ack = NativeMainMessage::session_token_response_ack("conn-1")
983            .with_reciprocal_session_token_ack("presentation-1", true, Some("user-device"), None);
984        assert_eq!(
985            reciprocal_ack.payload.get("reciprocalPresentationId"),
986            Some(&serde_json::Value::String("presentation-1".to_string())),
987        );
988        assert_eq!(
989            reciprocal_ack.payload.get("reciprocalAccepted"),
990            Some(&serde_json::Value::Bool(true)),
991        );
992    }
993}