openrtc 0.2.1

OpenRTC: a Rust-first P2P runtime for device discovery, signaling, and iroh/QUIC networking.
Documentation
use crate::signaling::{Device, DeviceCapabilities};

pub(crate) const DEVICE_STALE_HEARTBEAT_MS: i64 = 300_000;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const DEVICE_STALE_SWEEP_INTERVAL_SECS: u64 = 30;
pub(crate) const DEVICE_OFFLINE_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1000;

#[derive(Debug, Clone)]
pub(crate) struct SharedDeviceRecord {
    pub canonical_device_id: Option<String>,
    pub app_tag: Option<String>,
    pub user_id: Option<String>,
    pub device_name: String,
    pub platform_type: Option<String>,
    pub capabilities: Option<DeviceCapabilities>,
    pub session_id: Option<String>,
    pub node_id: Option<String>,
    pub tag: Option<String>,
    pub kind: Option<String>,
    pub metadata: Option<String>,
    pub online: bool,
    pub ticket: Option<String>,
    pub last_seen_at: Option<serde_json::Value>,
    pub expires_at: Option<serde_json::Value>,
    pub created_at: Option<serde_json::Value>,
    pub updated_at: Option<serde_json::Value>,
    pub excluded_peers: Vec<String>,
}

impl SharedDeviceRecord {
    pub(crate) fn effective_app_tag(&self) -> Option<&str> {
        self.app_tag.as_deref().or(self.tag.as_deref())
    }

    pub(crate) fn public_device_id(&self, document_id: &str) -> String {
        self.canonical_device_id
            .clone()
            .unwrap_or_else(|| document_id.to_string())
    }

    pub(crate) fn into_public_device(self, document_id: String) -> Device {
        let device_id = self.public_device_id(&document_id);
        let app_tag = self.app_tag.or_else(|| self.tag.clone());
        Device {
            app_tag,
            device_id,
            user_id: self.user_id,
            device_name: self.device_name,
            platform_type: self.platform_type,
            capabilities: self.capabilities,
            session_id: self.session_id,
            node_id: self.node_id,
            tag: self.tag,
            kind: self.kind,
            metadata: self.metadata,
            online: self.online,
            ticket: self.ticket,
            last_seen_at: self.last_seen_at,
            expires_at: self.expires_at,
            created_at: self.created_at,
            updated_at: self.updated_at,
            excluded_peers: self.excluded_peers,
        }
    }
}

/// Discovery retention semantics per OpenRTC avenue.
///
/// Rooms/spaces are live-only rosters: a record whose heartbeat lapses leaves
/// discovery immediately. User-scope device rosters are persistent: records
/// stay discoverable with degraded presence (`idle`/`offline`) until the
/// offline-retention window lapses, and the app layer decides how to filter
/// live vs stale devices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiscoveryRetention {
    Ephemeral,
    Persistent,
}

pub(crate) trait FirebaseDeviceRegistryContract {
    fn registry_app_tag(&self) -> &str;

    fn registry_retention(&self) -> DiscoveryRetention {
        DiscoveryRetention::Ephemeral
    }

    fn project_discovered_device(
        &self,
        document_id: String,
        record: SharedDeviceRecord,
        exclude_node_id: Option<&str>,
        now_ms: i64,
    ) -> Option<Device> {
        if record.effective_app_tag() != Some(self.registry_app_tag()) {
            return None;
        }

        if matches_excluded_identity(&record, &document_id, exclude_node_id) {
            return None;
        }

        if !is_device_record_discoverable(&record, now_ms, self.registry_retention()) {
            return None;
        }

        Some(record.into_public_device(document_id))
    }
}

pub(crate) fn is_device_record_discoverable(
    record: &SharedDeviceRecord,
    now_ms: i64,
    retention: DiscoveryRetention,
) -> bool {
    match retention {
        DiscoveryRetention::Ephemeral => {
            !is_device_record_expired(record, now_ms, DEVICE_STALE_HEARTBEAT_MS)
        }
        DiscoveryRetention::Persistent => {
            !is_device_record_beyond_offline_retention(record, now_ms)
        }
    }
}

/// Persistent-avenue drop deadline.
///
/// Online documents carry a short heartbeat `expiresAt` (now + heartbeat TTL);
/// honoring it verbatim would evict a persistent device the moment its app is
/// backgrounded. Instead a persistent record is only dropped once BOTH its
/// explicit `expiresAt` and its freshest activity timestamp plus the
/// offline-retention window are in the past.
fn is_device_record_beyond_offline_retention(record: &SharedDeviceRecord, now_ms: i64) -> bool {
    let explicit_expires_at = parse_millis(record.expires_at.as_ref());
    let freshest_activity = [
        record.updated_at.as_ref(),
        record.last_seen_at.as_ref(),
        record.created_at.as_ref(),
    ]
    .into_iter()
    .flatten()
    .filter_map(|value| parse_millis(Some(value)))
    .max();

    let deadline = match (explicit_expires_at, freshest_activity) {
        (Some(expires_at), Some(activity)) => {
            expires_at.max(activity.saturating_add(DEVICE_OFFLINE_RETENTION_MS))
        }
        (Some(expires_at), None) => expires_at,
        (None, Some(activity)) => activity.saturating_add(DEVICE_OFFLINE_RETENTION_MS),
        (None, None) => return false,
    };

    deadline <= now_ms
}

pub(crate) fn offline_device_expires_at_ms(now_ms: i64) -> i64 {
    now_ms.saturating_add(DEVICE_OFFLINE_RETENTION_MS)
}

pub(crate) fn is_device_record_expired(
    record: &SharedDeviceRecord,
    now_ms: i64,
    online_stale_after_ms: i64,
) -> bool {
    if let Some(expires_at) = parse_millis(record.expires_at.as_ref()) {
        return expires_at <= now_ms;
    }

    let fallback_ttl_ms = if record.online {
        online_stale_after_ms
    } else {
        DEVICE_OFFLINE_RETENTION_MS
    };

    if let Some(updated_at) = parse_millis(record.updated_at.as_ref()) {
        return updated_at.saturating_add(fallback_ttl_ms) <= now_ms;
    }

    if let Some(last_seen_at) = parse_millis(record.last_seen_at.as_ref()) {
        return last_seen_at.saturating_add(fallback_ttl_ms) <= now_ms;
    }

    false
}

pub(crate) fn matches_excluded_identity(
    record: &SharedDeviceRecord,
    document_id: &str,
    exclude_node_id: Option<&str>,
) -> bool {
    let Some(exclude) = exclude_node_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return false;
    };

    record.public_device_id(document_id) == exclude
        || record.node_id.as_deref() == Some(exclude)
        || record.ticket.as_deref() == Some(exclude)
        || document_id == exclude
}

pub(crate) fn parse_millis(value: Option<&serde_json::Value>) -> Option<i64> {
    match value {
        Some(serde_json::Value::Number(number)) => number.as_i64(),
        Some(serde_json::Value::String(raw)) => parse_millis_string(raw),
        _ => None,
    }
}

fn parse_millis_string(raw: &str) -> Option<i64> {
    if let Ok(parsed) = raw.parse::<i64>() {
        return Some(parsed);
    }

    #[cfg(target_arch = "wasm32")]
    {
        let millis = js_sys::Date::parse(raw);
        if millis.is_finite() {
            return Some(millis as i64);
        }
    }

    #[cfg(not(target_arch = "wasm32"))]
    {
        if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(raw) {
            return Some(parsed.timestamp_millis());
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn record(
        online: bool,
        updated_at: Option<i64>,
        expires_at: Option<i64>,
    ) -> SharedDeviceRecord {
        SharedDeviceRecord {
            canonical_device_id: Some("device-1".to_string()),
            app_tag: Some("app_test".to_string()),
            user_id: Some("user-1".to_string()),
            device_name: "Device 1".to_string(),
            platform_type: Some("desktop".to_string()),
            capabilities: None,
            session_id: None,
            node_id: Some("node-1".to_string()),
            tag: Some("app_test".to_string()),
            kind: Some("device".to_string()),
            metadata: None,
            online,
            ticket: None,
            last_seen_at: None,
            expires_at: expires_at.map(serde_json::Value::from),
            created_at: None,
            updated_at: updated_at.map(serde_json::Value::from),
            excluded_peers: vec![],
        }
    }

    #[test]
    fn online_records_expire_on_heartbeat_ttl() {
        let now = 1_000_000;
        let stale = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);

        assert!(is_device_record_expired(
            &stale,
            now,
            DEVICE_STALE_HEARTBEAT_MS,
        ));
    }

    #[test]
    fn offline_records_without_expires_at_use_offline_retention() {
        let now = 1_000_000_000;
        let offline_recent = record(false, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
        let offline_expired = record(false, Some(now - DEVICE_OFFLINE_RETENTION_MS - 1), None);

        assert!(!is_device_record_expired(
            &offline_recent,
            now,
            DEVICE_STALE_HEARTBEAT_MS,
        ));
        assert!(is_device_record_expired(
            &offline_expired,
            now,
            DEVICE_STALE_HEARTBEAT_MS,
        ));
    }

    #[test]
    fn persistent_retention_keeps_stale_online_records_discoverable() {
        let now = 1_000_000_000;
        // Online record whose heartbeat lapsed (backgrounded app): its explicit
        // expiresAt is in the past, but activity is well within retention.
        let mut stale_online = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
        stale_online.expires_at = Some(serde_json::Value::from(now - 1));

        assert!(is_device_record_discoverable(
            &stale_online,
            now,
            DiscoveryRetention::Persistent,
        ));
        assert!(!is_device_record_discoverable(
            &stale_online,
            now,
            DiscoveryRetention::Ephemeral,
        ));
    }

    #[test]
    fn persistent_retention_drops_records_past_offline_retention() {
        let now = 100_000_000_000;
        let beyond_retention = record(true, Some(now - DEVICE_OFFLINE_RETENTION_MS - 1), None);
        let within_retention = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);

        assert!(!is_device_record_discoverable(
            &beyond_retention,
            now,
            DiscoveryRetention::Persistent,
        ));
        assert!(is_device_record_discoverable(
            &within_retention,
            now,
            DiscoveryRetention::Persistent,
        ));
    }

    #[test]
    fn explicit_expires_at_wins_for_offline_records() {
        let now = 1_000_000_000;
        let expired = record(false, Some(now), Some(now - 1));
        let retained = record(
            false,
            Some(now - DEVICE_OFFLINE_RETENTION_MS - 1),
            Some(now + 1),
        );

        assert!(is_device_record_expired(
            &expired,
            now,
            DEVICE_STALE_HEARTBEAT_MS,
        ));
        assert!(!is_device_record_expired(
            &retained,
            now,
            DEVICE_STALE_HEARTBEAT_MS,
        ));
    }
}