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}