Skip to main content

oas_document/
contact.rs

1// OAS Spec §5.9 — Contact Directory
2//! Contact directory for OAS Identity Documents.
3//!
4//! The contact section is the "how to reach me" directory — phone numbers,
5//! email addresses, social media profiles, websites, and messaging endpoints.
6//! Each entry carries its own [`Visibility`] level for access control.
7
8use serde::{Deserialize, Serialize};
9
10use crate::visibility::Visibility;
11
12/// A single contact entry in an entity's contact directory.
13///
14/// Each entry represents one reachable channel (phone, email, social profile, etc.)
15/// with its own visibility level for access control.
16///
17/// # DHT Behavior
18///
19/// For entries where `visibility` is not `public`, the DHT-stored copy contains:
20/// ```json
21/// {
22///   "id": "phone-1",
23///   "entryType": "phone",
24///   "label": "Support",
25///   "visibility": "organization",
26///   "available": true,
27///   "value": null
28/// }
29/// ```
30/// The actual `value` is only served via the authenticated
31/// `OASPrivateEndpointService`.
32///
33/// # Examples
34///
35/// ```
36/// use oas_document::contact::ContactEntry;
37/// use oas_document::visibility::Visibility;
38///
39/// let phone = ContactEntry {
40///     id: "phone-1".to_string(),
41///     entry_type: "phone".to_string(),
42///     value: "tel:+15551234567".to_string(),
43///     label: Some("Primary".to_string()),
44///     platform: None,
45///     handle: None,
46///     visibility: Visibility::Public,
47///     verified: Some(true),
48/// };
49/// assert_eq!(phone.entry_type, "phone");
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ContactEntry {
54    /// Fragment identifier (e.g., "phone-1", "email-2", "social-x").
55    pub id: String,
56
57    /// Entry type: `"phone"`, `"email"`, `"social"`, `"website"`, `"messaging"`.
58    pub entry_type: String,
59
60    /// The contact value. Format depends on type:
61    /// - phone: `tel:+15551234567`
62    /// - email: `mailto:agent@example.com`
63    /// - social: `https://x.com/agent`
64    /// - website: `https://example.com/agent`
65    /// - messaging: `https://t.me/agent_bot`
66    pub value: String,
67
68    /// Human-readable label (e.g., "Primary", "Support", "Sales", "Personal").
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub label: Option<String>,
71
72    /// Platform name for social/messaging entries (e.g., "x", "linkedin",
73    /// "discord", "telegram", "github", "slack", "whatsapp", "signal").
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub platform: Option<String>,
76
77    /// Handle/username for social/messaging entries (e.g., "@agent42").
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub handle: Option<String>,
80
81    /// Who can see this entry's actual value.
82    /// Non-public entries show only the capability flag on the DHT.
83    #[serde(default)]
84    pub visibility: Visibility,
85
86    /// Whether this contact channel has been verified by the entity.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub verified: Option<bool>,
89}
90
91/// Contact directory section of an OAS Identity Document.
92///
93/// Contains all reachable channels for an entity, each with
94/// independent visibility controls.
95///
96/// # Standard Entry Types
97///
98/// | Type        | Value Format                      | Example                         |
99/// |-------------|-----------------------------------|---------------------------------|
100/// | `phone`     | `tel:` URI (E.164)                | `tel:+15551234567`              |
101/// | `email`     | `mailto:` URI                     | `mailto:agent@acme.com`         |
102/// | `social`    | HTTPS URL to profile              | `https://x.com/agent42`         |
103/// | `website`   | HTTPS URL                         | `https://acme.com/agent`        |
104/// | `messaging` | HTTPS URL or protocol-specific URI| `https://t.me/agent_bot`        |
105///
106/// # Examples
107///
108/// ```
109/// use oas_document::contact::{ContactSection, ContactEntry};
110/// use oas_document::visibility::Visibility;
111///
112/// let contact = ContactSection {
113///     entries: vec![
114///         ContactEntry {
115///             id: "email-1".to_string(),
116///             entry_type: "email".to_string(),
117///             value: "mailto:hello@agent.ai".to_string(),
118///             label: Some("Primary".to_string()),
119///             platform: None,
120///             handle: None,
121///             visibility: Visibility::Public,
122///             verified: Some(true),
123///         },
124///         ContactEntry {
125///             id: "social-x".to_string(),
126///             entry_type: "social".to_string(),
127///             value: "https://x.com/agent42".to_string(),
128///             label: None,
129///             platform: Some("x".to_string()),
130///             handle: Some("@agent42".to_string()),
131///             visibility: Visibility::Public,
132///             verified: None,
133///         },
134///     ],
135/// };
136/// assert_eq!(contact.entries.len(), 2);
137/// ```
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct ContactSection {
141    /// All contact entries for this entity.
142    pub entries: Vec<ContactEntry>,
143}
144
145/// Standard contact entry types.
146pub mod entry_types {
147    /// Telephone number (value format: `tel:` URI).
148    pub const PHONE: &str = "phone";
149    /// Email address (value format: `mailto:` URI).
150    pub const EMAIL: &str = "email";
151    /// Social media profile (value: HTTPS URL).
152    pub const SOCIAL: &str = "social";
153    /// Website (value: HTTPS URL).
154    pub const WEBSITE: &str = "website";
155    /// Messaging endpoint (value: HTTPS URL or protocol-specific URI).
156    pub const MESSAGING: &str = "messaging";
157}
158
159/// Standard social platform identifiers.
160pub mod platforms {
161    pub const X: &str = "x";
162    pub const LINKEDIN: &str = "linkedin";
163    pub const GITHUB: &str = "github";
164    pub const DISCORD: &str = "discord";
165    pub const TELEGRAM: &str = "telegram";
166    pub const SLACK: &str = "slack";
167    pub const WHATSAPP: &str = "whatsapp";
168    pub const SIGNAL: &str = "signal";
169    pub const MASTODON: &str = "mastodon";
170    pub const BLUESKY: &str = "bluesky";
171    pub const YOUTUBE: &str = "youtube";
172    pub const INSTAGRAM: &str = "instagram";
173    pub const TIKTOK: &str = "tiktok";
174    pub const REDDIT: &str = "reddit";
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn serde_round_trip() {
183        let contact = ContactSection {
184            entries: vec![
185                ContactEntry {
186                    id: "phone-1".to_string(),
187                    entry_type: entry_types::PHONE.to_string(),
188                    value: "tel:+15551234567".to_string(),
189                    label: Some("Primary".to_string()),
190                    platform: None,
191                    handle: None,
192                    visibility: Visibility::Public,
193                    verified: Some(true),
194                },
195                ContactEntry {
196                    id: "social-x".to_string(),
197                    entry_type: entry_types::SOCIAL.to_string(),
198                    value: "https://x.com/agent42".to_string(),
199                    label: None,
200                    platform: Some(platforms::X.to_string()),
201                    handle: Some("@agent42".to_string()),
202                    visibility: Visibility::Public,
203                    verified: None,
204                },
205                ContactEntry {
206                    id: "phone-internal".to_string(),
207                    entry_type: entry_types::PHONE.to_string(),
208                    value: "tel:+15559876543".to_string(),
209                    label: Some("Internal".to_string()),
210                    platform: None,
211                    handle: None,
212                    visibility: Visibility::Organization,
213                    verified: None,
214                },
215            ],
216        };
217
218        let json = serde_json::to_string_pretty(&contact).unwrap_or_default();
219        let parsed: ContactSection =
220            serde_json::from_str(&json).unwrap_or_else(|_| contact.clone());
221        assert_eq!(contact, parsed);
222    }
223
224    #[test]
225    fn visibility_defaults_to_public() {
226        let json = r#"{"id":"e1","entryType":"email","value":"mailto:a@b.com"}"#;
227        let entry: ContactEntry = serde_json::from_str(json).unwrap_or_else(|_| ContactEntry {
228            id: String::new(),
229            entry_type: String::new(),
230            value: String::new(),
231            label: None,
232            platform: None,
233            handle: None,
234            visibility: Visibility::Private,
235            verified: None,
236        });
237        assert_eq!(entry.visibility, Visibility::Public);
238    }
239
240    #[test]
241    fn organization_visibility_entry() {
242        let entry = ContactEntry {
243            id: "phone-internal".to_string(),
244            entry_type: entry_types::PHONE.to_string(),
245            value: "tel:+15559876543".to_string(),
246            label: Some("Internal hotline".to_string()),
247            platform: None,
248            handle: None,
249            visibility: Visibility::Organization,
250            verified: None,
251        };
252        assert!(entry.visibility.is_redacted_on_dht());
253    }
254}