Skip to main content

xmtp_mls_core/
notifications.rs

1//! The local notification settings model and the pure rules over it.
2
3use crate::context::CoreContext;
4use serde::{Deserialize, Serialize};
5use xmtp_cryptography::secret::{Secret, Zeroizing};
6use xmtp_db::consent_record::ConsentState;
7use xmtp_db::{StorageError, StoredSecret, notifications::StoredNotification};
8use xmtp_proto::backend_v1::{self, register_request::Delivery};
9
10/// Invalidates prepared notification batches and emits
11/// `NotificationSettingsChanged`.
12pub fn notification_settings_changed(context: &impl CoreContext) {
13    context.task_channels().mark_notification_changed();
14    xmtp_events::EventWriter::emit(
15        context.events(),
16        None,
17        Some(crate::events::InternalEvent::NotificationSettingsChanged),
18    );
19}
20
21/// The delivery endpoint. Credentials are omitted from debug output.
22///
23/// The HTTP signing key serializes as a `Vec<u8>` does, so stored
24/// configurations do not change.
25#[derive(Clone, Serialize, Deserialize)]
26pub enum NotificationChannel {
27    Apns { token: String },
28    Fcm { token: String },
29    Http { url: String, signing_key: Secret },
30}
31
32impl std::fmt::Debug for NotificationChannel {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.write_str(match self {
35            Self::Apns { .. } => "Apns",
36            Self::Fcm { .. } => "Fcm",
37            Self::Http { .. } => "Http",
38        })
39    }
40}
41
42/// Delivery details and rules used to compute the desired subscriptions.
43#[derive(Clone, Serialize, Deserialize)]
44pub struct NotificationConfig {
45    pub channel: NotificationChannel,
46    pub consent_states: Vec<ConsentState>,
47    pub include_welcomes: bool,
48    pub include_sync_groups: bool,
49    pub include_commits: bool,
50}
51
52impl NotificationConfig {
53    /// Use the standard rules with the supplied delivery endpoint.
54    pub fn new(channel: NotificationChannel) -> Self {
55        Self {
56            channel,
57            consent_states: vec![ConsentState::Allowed],
58            include_welcomes: true,
59            include_sync_groups: false,
60            include_commits: false,
61        }
62    }
63
64    /// The backend channel code for this endpoint.
65    pub fn channel_id(&self) -> i32 {
66        match self.channel {
67            NotificationChannel::Apns { .. } => backend_v1::Channel::Apns as i32,
68            NotificationChannel::Fcm { .. } => backend_v1::Channel::Fcm as i32,
69            NotificationChannel::Http { .. } => backend_v1::Channel::Http as i32,
70        }
71    }
72
73    /// The request copies the signing key and the recipient secret. Each copy
74    /// of the request wipes them on drop.
75    pub fn registration(&self, record: &StoredNotification) -> backend_v1::RegisterRequest {
76        let delivery = match &self.channel {
77            NotificationChannel::Apns { token } => Delivery::Apns(backend_v1::ApnsDelivery {
78                token: token.clone(),
79            }),
80            NotificationChannel::Fcm { token } => Delivery::Fcm(backend_v1::FcmDelivery {
81                token: token.clone(),
82            }),
83            NotificationChannel::Http { url, signing_key } => {
84                Delivery::Http(backend_v1::HttpDelivery {
85                    url: url.clone(),
86                    signing_key: signing_key.as_slice().to_vec(),
87                })
88            }
89        };
90        backend_v1::RegisterRequest {
91            recipient_id: record.push_recipient_id.clone().unwrap_or_default(),
92            recipient_secret: recipient_secret(record),
93            delivery: Some(delivery),
94        }
95    }
96}
97
98/// A copy of the stored recipient secret for a request, which wipes it on drop.
99pub fn recipient_secret(record: &StoredNotification) -> Vec<u8> {
100    record
101        .push_recipient_secret
102        .as_ref()
103        .map(|secret| secret.as_slice().to_vec())
104        .unwrap_or_default()
105}
106
107/// A conversation rule. Sync groups use the client rule only.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum NotificationOverride {
110    Enabled,
111    Disabled,
112    Default,
113}
114
115/// Encodes a stored notification value as JSON.
116pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, StorageError> {
117    serde_json::to_vec(value).map_err(|_| StorageError::DbSerialize)
118}
119
120/// Decodes a stored notification value from JSON.
121pub fn decode<T: serde::de::DeserializeOwned>(value: &[u8]) -> Result<T, StorageError> {
122    serde_json::from_slice(value).map_err(|_| StorageError::DbDeserialize)
123}
124
125/// Encode a configuration, which holds the HTTP signing key, for storage.
126///
127/// The same JSON as [`encode`]. A first pass measures it, so the zeroizing
128/// buffer never grows and frees a partial copy of the key.
129pub fn encode_config(config: &NotificationConfig) -> Result<StoredSecret, StorageError> {
130    struct Measure(usize);
131    impl std::io::Write for Measure {
132        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
133            self.0 += bytes.len();
134            Ok(bytes.len())
135        }
136        fn flush(&mut self) -> std::io::Result<()> {
137            Ok(())
138        }
139    }
140    let mut measure = Measure(0);
141    serde_json::to_writer(&mut measure, config).map_err(|_| StorageError::DbSerialize)?;
142    let mut bytes = Zeroizing::new(Vec::with_capacity(measure.0));
143    serde_json::to_writer(&mut *bytes, config).map_err(|_| StorageError::DbSerialize)?;
144    Ok(bytes.into())
145}
146
147/// Decode the stored configuration. The stored bytes stay in their
148/// `StoredSecret`, and the signing key decodes straight into a `Secret`.
149pub fn stored_config(record: &StoredNotification) -> Result<NotificationConfig, StorageError> {
150    decode(
151        record
152            .push_config
153            .as_ref()
154            .ok_or(StorageError::DbDeserialize)?
155            .as_slice(),
156    )
157}
158
159/// Resolve only local rules. Active membership is checked separately by the task.
160pub fn effective(
161    config: &NotificationConfig,
162    group: &xmtp_db::group::StoredGroup,
163    consent: ConsentState,
164) -> bool {
165    use xmtp_proto::types::ConversationType;
166    if group.conversation_type == ConversationType::Sync {
167        return config.include_sync_groups;
168    }
169    if !matches!(
170        group.conversation_type,
171        ConversationType::Group | ConversationType::Dm
172    ) {
173        return false;
174    }
175    match group.push_override {
176        Some(1) => true,
177        Some(0) => false,
178        _ => config.consent_states.contains(&consent),
179    }
180}
181
182/// Whether notifications for `group` are on: the record is enabled and its
183/// stored configuration's local rules allow the group.
184pub fn enabled_from_record(
185    record: &StoredNotification,
186    group: &xmtp_db::group::StoredGroup,
187    consent: ConsentState,
188) -> Result<bool, StorageError> {
189    if record.push_state != 1 {
190        return Ok(false);
191    }
192    let config = stored_config(record)?;
193    Ok(effective(&config, group, consent))
194}