Skip to main content

nexo_pairing/
types.rs

1//! Public types shared by store / gate / setup-code.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct PendingRequest {
8    pub channel: String,
9    pub account_id: String,
10    pub sender_id: String,
11    pub code: String,
12    pub created_at: DateTime<Utc>,
13    #[serde(default)]
14    pub meta: serde_json::Value,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApprovedRequest {
19    pub channel: String,
20    pub account_id: String,
21    pub sender_id: String,
22    pub approved_at: DateTime<Utc>,
23}
24
25#[derive(Debug, Clone)]
26pub struct UpsertOutcome {
27    pub code: String,
28    /// `true` when this call inserted a new pending row, `false` when
29    /// it returned the code from an existing one.
30    pub created: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Decision {
35    /// Sender is in `allow_from`; publish as normal.
36    Admit,
37    /// Sender is unknown; reply with `code` and drop the message.
38    Challenge { code: String },
39    /// Drop without reply (max-pending exhausted, or `auto_challenge`
40    /// is off and sender is unknown).
41    Drop,
42}
43
44#[derive(Debug, Clone, Default, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct PairingPolicy {
47    /// When `true`, unknown senders trigger a challenge reply. When
48    /// `false`, the gate is a no-op (every message is admitted).
49    /// Default `false` keeps existing setups working without changes.
50    #[serde(default)]
51    pub auto_challenge: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SetupCode {
56    pub url: String,
57    pub bootstrap_token: String,
58    pub expires_at: DateTime<Utc>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TokenClaims {
63    pub profile: String,
64    pub expires_at: DateTime<Utc>,
65    pub nonce: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub device_label: Option<String>,
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum PairingError {
72    #[error("unknown code")]
73    UnknownCode,
74    #[error("expired")]
75    Expired,
76    #[error("invalid signature")]
77    InvalidSignature,
78    #[error("max pending reached for {channel}:{account_id}")]
79    MaxPending { channel: String, account_id: String },
80    #[error("storage: {0}")]
81    Storage(String),
82    #[error("io: {0}")]
83    Io(String),
84    #[error("invalid: {0}")]
85    Invalid(&'static str),
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn pending_round_trip() {
94        let p = PendingRequest {
95            channel: "whatsapp".into(),
96            account_id: "personal".into(),
97            sender_id: "+57111".into(),
98            code: "ABCDEFGH".into(),
99            created_at: Utc::now(),
100            meta: serde_json::json!({"k":"v"}),
101        };
102        let s = serde_json::to_string(&p).unwrap();
103        let p2: PendingRequest = serde_json::from_str(&s).unwrap();
104        assert_eq!(p.code, p2.code);
105        assert_eq!(p.meta, p2.meta);
106    }
107
108    #[test]
109    fn policy_defaults_off() {
110        let p: PairingPolicy = serde_json::from_str("{}").unwrap();
111        assert!(!p.auto_challenge);
112    }
113
114    #[test]
115    fn policy_rejects_unknown_keys() {
116        let res: Result<PairingPolicy, _> = serde_json::from_str("{\"bogus\": true}");
117        assert!(res.is_err());
118    }
119}