Skip to main content

baukit_push/
registry.rs

1//! The device registry port: which push tokens belong to which owner.
2
3use std::{fmt, future::Future, num::NonZeroU32, pin::Pin};
4
5use chrono::{DateTime, Utc};
6use thiserror::Error;
7use uuid::Uuid;
8
9use crate::PushOutcome;
10
11/// Longest device token the registry accepts, in bytes.
12pub const MAX_DEVICE_TOKEN_LENGTH: usize = 512;
13
14/// Longest device time zone name the registry accepts, in bytes.
15pub const MAX_DEVICE_TIME_ZONE_LENGTH: usize = 64;
16
17/// Devices an owner keeps when a store is built without an explicit cap.
18pub const DEFAULT_DEVICES_PER_OWNER: NonZeroU32 = NonZeroU32::new(10).expect("ten is not zero");
19
20/// A value rejected before it reached a store.
21///
22/// The display text names the field only. It never repeats the rejected value,
23/// so the error is safe to log or return to a client.
24#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
25pub enum PushValidationError {
26    /// The device token is empty, too long, or not printable ASCII.
27    #[error("device token is invalid")]
28    DeviceToken,
29    /// The time zone name is empty, too long, or has characters outside an IANA name.
30    #[error("device time zone is invalid")]
31    DeviceTimeZone,
32    /// The delivery kind is empty, too long, or has characters outside `[a-z0-9_.-]`.
33    #[error("delivery kind is invalid")]
34    DeliveryKind,
35    /// The provider ticket ID is empty, too long, or not printable ASCII.
36    #[error("push ticket id is invalid")]
37    TicketId,
38}
39
40/// A provider-issued device token.
41///
42/// A token addresses one app installation, so anyone holding it can send that
43/// device a notification. `Debug` redacts it and there is no `Display`; read
44/// it with [`DeviceToken::expose`] only where it leaves for the provider or the
45/// store.
46#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct DeviceToken(String);
48
49impl DeviceToken {
50    /// Validates a token: 1 to [`MAX_DEVICE_TOKEN_LENGTH`] bytes of visible ASCII.
51    ///
52    /// Trim the value before calling this; surrounding whitespace is rejected
53    /// rather than silently removed.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`PushValidationError::DeviceToken`] for any other value.
58    pub fn new(token: impl Into<String>) -> Result<Self, PushValidationError> {
59        let token = token.into();
60        let valid = !token.is_empty()
61            && token.len() <= MAX_DEVICE_TOKEN_LENGTH
62            && token.bytes().all(|byte| byte.is_ascii_graphic());
63        if valid {
64            Ok(Self(token))
65        } else {
66            Err(PushValidationError::DeviceToken)
67        }
68    }
69
70    /// Returns the token for a provider request or a store query.
71    #[must_use]
72    pub fn expose(&self) -> &str {
73        &self.0
74    }
75}
76
77impl fmt::Debug for DeviceToken {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter.write_str("DeviceToken(<redacted>)")
80    }
81}
82
83/// The time zone a device reported when it registered, as an IANA name.
84///
85/// The registry checks the shape only. Resolve the name with a time zone
86/// database before registering if the product schedules by it.
87#[derive(Clone, Debug, Eq, Hash, PartialEq)]
88pub struct DeviceTimeZone(String);
89
90impl DeviceTimeZone {
91    /// Validates the shape of an IANA name such as `Europe/Berlin` or `Etc/GMT+5`.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`PushValidationError::DeviceTimeZone`] when the name is empty,
96    /// longer than [`MAX_DEVICE_TIME_ZONE_LENGTH`], or has a character outside
97    /// ASCII letters, digits, `/`, `_`, `+`, and `-`.
98    pub fn new(name: impl Into<String>) -> Result<Self, PushValidationError> {
99        let name = name.into();
100        let valid = !name.is_empty()
101            && name.len() <= MAX_DEVICE_TIME_ZONE_LENGTH
102            && name
103                .bytes()
104                .all(|byte| byte.is_ascii_alphanumeric() || b"/_+-".contains(&byte));
105        if valid {
106            Ok(Self(name))
107        } else {
108            Err(PushValidationError::DeviceTimeZone)
109        }
110    }
111
112    /// Returns the IANA name.
113    #[must_use]
114    pub fn as_str(&self) -> &str {
115        &self.0
116    }
117}
118
119/// The mobile platform a device token belongs to.
120#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
121pub enum DevicePlatform {
122    /// Apple iOS or iPadOS.
123    Ios,
124    /// Google Android.
125    Android,
126}
127
128impl DevicePlatform {
129    /// Returns the stored form, `ios` or `android`.
130    #[must_use]
131    pub const fn as_str(self) -> &'static str {
132        match self {
133            Self::Ios => "ios",
134            Self::Android => "android",
135        }
136    }
137
138    /// Parses the stored form.
139    #[must_use]
140    pub fn parse(value: &str) -> Option<Self> {
141        match value {
142            "ios" => Some(Self::Ios),
143            "android" => Some(Self::Android),
144            _ => None,
145        }
146    }
147}
148
149/// One device's request to receive pushes for an owner.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct DeviceRegistration {
152    /// The account the device receives notifications for.
153    pub owner_id: Uuid,
154    /// The device's current provider token.
155    pub token: DeviceToken,
156    /// The platform the token belongs to.
157    pub platform: DevicePlatform,
158    /// The time zone the device reported, if the product schedules by it.
159    pub time_zone: Option<DeviceTimeZone>,
160    /// The service instant of this registration.
161    pub registered_at: DateTime<Utc>,
162}
163
164impl DeviceRegistration {
165    /// Creates a registration without a time zone.
166    #[must_use]
167    pub const fn new(
168        owner_id: Uuid,
169        token: DeviceToken,
170        platform: DevicePlatform,
171        registered_at: DateTime<Utc>,
172    ) -> Self {
173        Self {
174            owner_id,
175            token,
176            platform,
177            time_zone: None,
178            registered_at,
179        }
180    }
181
182    /// Records the time zone the device reported.
183    #[must_use]
184    pub fn with_time_zone(mut self, time_zone: DeviceTimeZone) -> Self {
185        self.time_zone = Some(time_zone);
186        self
187    }
188}
189
190/// A device as the registry stores it.
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct RegisteredDevice {
193    /// The account the device receives notifications for.
194    pub owner_id: Uuid,
195    /// The device's provider token.
196    pub token: DeviceToken,
197    /// The platform the token belongs to.
198    pub platform: DevicePlatform,
199    /// The time zone from the latest registration.
200    pub time_zone: Option<DeviceTimeZone>,
201    /// When this owner first registered the token.
202    pub created_at: DateTime<Utc>,
203    /// The latest registration instant; eviction and invalidation compare it.
204    pub last_registered_at: DateTime<Utc>,
205}
206
207/// What a registration or rotation changed besides the registered device.
208#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
209pub struct RegistrationOutcome {
210    /// Older devices of the same owner removed to stay within the cap.
211    pub evicted: u64,
212}
213
214/// A store failure.
215///
216/// The display text is fixed. The private diagnostics come from the database
217/// driver's message, which carries no bound values, so neither form contains a
218/// device token.
219#[derive(Clone, Debug, Eq, Error, PartialEq)]
220pub enum PushStoreError {
221    /// The adapter could not complete the operation.
222    #[error("push store failed")]
223    Internal(String),
224}
225
226impl PushStoreError {
227    /// Wraps private adapter diagnostics without adding them to display text.
228    pub fn internal(error: impl fmt::Display) -> Self {
229        Self::Internal(error.to_string())
230    }
231}
232
233/// The future returned by [`DeviceRegistry`] and [`DeliveryClaimStore`](crate::DeliveryClaimStore) methods.
234pub type PushStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
235
236/// Storage port for the push tokens each owner's devices registered.
237///
238/// A token belongs to at most one owner. Registering a token another owner
239/// holds moves it, so a shared device stops receiving the previous account's
240/// notifications. Each owner keeps at most a configured number of devices;
241/// a registration over the cap evicts that owner's least recently registered
242/// devices, never the one being registered. Ties break on `created_at`, then
243/// on the token, so eviction is deterministic.
244pub trait DeviceRegistry: Send + Sync {
245    /// Registers or refreshes one device and evicts over the owner's cap.
246    fn register(
247        &self,
248        registration: DeviceRegistration,
249    ) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
250
251    /// Replaces the owner's `previous` token with the registration's token in one step.
252    ///
253    /// The predecessor is removed before the cap is enforced, so rotating
254    /// never evicts another device. A predecessor the owner does not hold is
255    /// left alone, which makes a retried rotation safe.
256    fn rotate(
257        &self,
258        previous: DeviceToken,
259        registration: DeviceRegistration,
260    ) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
261
262    /// Removes one of the owner's devices; returns whether it existed.
263    fn unregister(
264        &self,
265        owner_id: Uuid,
266        token: DeviceToken,
267    ) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
268
269    /// Lists the owner's devices, most recently registered first.
270    fn list_for_owner(
271        &self,
272        owner_id: Uuid,
273    ) -> PushStoreFuture<'_, Result<Vec<RegisteredDevice>, PushStoreError>>;
274
275    /// Removes tokens the provider reported dead; returns how many went.
276    ///
277    /// Pass the instant the send started as `sent_at`. A device registered
278    /// again after it keeps its token, because the provider's verdict predates
279    /// the new registration.
280    fn invalidate(
281        &self,
282        tokens: Vec<DeviceToken>,
283        sent_at: DateTime<Utc>,
284    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
285
286    /// Removes every device of one owner; returns how many went.
287    fn erase_owner(&self, owner_id: Uuid) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
288
289    /// Invalidates every token a send reported as [`PushOutcome::is_token_dead`].
290    ///
291    /// This is the one call to make after [`PushSender::send`](crate::PushSender::send)
292    /// returns. Outcomes without a dead token cost no store round trip.
293    fn invalidate_dead_tokens(
294        &self,
295        outcomes: &[PushOutcome],
296        sent_at: DateTime<Utc>,
297    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>> {
298        let tokens = dead_tokens(outcomes);
299        if tokens.is_empty() {
300            return Box::pin(std::future::ready(Ok(0)));
301        }
302        self.invalidate(tokens, sent_at)
303    }
304}
305
306/// Returns the distinct tokens a send reported as dead.
307#[must_use]
308pub fn dead_tokens(outcomes: &[PushOutcome]) -> Vec<DeviceToken> {
309    let mut tokens = outcomes
310        .iter()
311        .filter(|outcome| outcome.is_token_dead())
312        .map(|outcome| outcome.token.clone())
313        .collect::<Vec<_>>();
314    tokens.sort();
315    tokens.dedup();
316    tokens
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::{PushDeliveryStatus, PushRejection, PushTicketId};
323
324    const EXPO_TOKEN: &str = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]";
325
326    #[test]
327    fn a_token_accepts_visible_ascii_up_to_the_limit() {
328        assert!(DeviceToken::new(EXPO_TOKEN).is_ok());
329        assert!(DeviceToken::new("a".repeat(MAX_DEVICE_TOKEN_LENGTH)).is_ok());
330        for invalid in [
331            String::new(),
332            "a".repeat(MAX_DEVICE_TOKEN_LENGTH + 1),
333            " leading".to_owned(),
334            "inner space".to_owned(),
335            "tab\t".to_owned(),
336            "umlaut-ΓΌ".to_owned(),
337        ] {
338            assert_eq!(
339                DeviceToken::new(invalid),
340                Err(PushValidationError::DeviceToken)
341            );
342        }
343    }
344
345    #[test]
346    fn a_token_never_appears_in_debug_output_or_errors() {
347        let token = DeviceToken::new(EXPO_TOKEN).expect("valid token");
348        let registration = DeviceRegistration::new(
349            Uuid::nil(),
350            token.clone(),
351            DevicePlatform::Ios,
352            DateTime::UNIX_EPOCH,
353        );
354        assert!(!format!("{token:?}").contains("Exponent"));
355        assert!(!format!("{registration:?}").contains("Exponent"));
356        assert_eq!(token.expose(), EXPO_TOKEN);
357
358        let error = DeviceToken::new(format!("{EXPO_TOKEN} ")).expect_err("space is invalid");
359        assert!(!format!("{error} {error:?}").contains("Exponent"));
360    }
361
362    #[test]
363    fn a_time_zone_accepts_iana_shaped_names_only() {
364        for valid in [
365            "UTC",
366            "Europe/Berlin",
367            "America/Argentina/Buenos_Aires",
368            "Etc/GMT+5",
369        ] {
370            assert_eq!(
371                DeviceTimeZone::new(valid).map(|zone| zone.as_str().to_owned()),
372                Ok(valid.to_owned())
373            );
374        }
375        for invalid in [
376            String::new(),
377            "Europe/Berlin ".to_owned(),
378            "x".repeat(MAX_DEVICE_TIME_ZONE_LENGTH + 1),
379            "Europe;Berlin".to_owned(),
380        ] {
381            assert_eq!(
382                DeviceTimeZone::new(invalid),
383                Err(PushValidationError::DeviceTimeZone)
384            );
385        }
386    }
387
388    #[test]
389    fn platforms_round_trip_through_their_stored_form() {
390        for platform in [DevicePlatform::Ios, DevicePlatform::Android] {
391            assert_eq!(DevicePlatform::parse(platform.as_str()), Some(platform));
392        }
393        assert_eq!(DevicePlatform::parse("web"), None);
394    }
395
396    #[test]
397    fn only_distinct_dead_tokens_are_invalidated() {
398        let outcome = |token: &str, status| PushOutcome {
399            token: DeviceToken::new(token).expect("valid test token"),
400            status,
401        };
402        let dead = || PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered);
403        let tokens = dead_tokens(&[
404            outcome("b", dead()),
405            outcome("a", dead()),
406            outcome("b", dead()),
407            outcome("c", PushDeliveryStatus::Delivered),
408            outcome(
409                "d",
410                PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
411            ),
412            outcome(
413                "e",
414                PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
415            ),
416        ]);
417        assert_eq!(
418            tokens.iter().map(DeviceToken::expose).collect::<Vec<_>>(),
419            ["a", "b"]
420        );
421    }
422}