1use serde::{Deserialize, Serialize};
8
9pub mod resolve;
10
11pub fn validate_organization_id(org: &str) -> Result<(), String> {
16 if org.is_empty()
17 || org.len() > 256
18 || !org
19 .bytes()
20 .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
21 {
22 return Err(format!(
23 "`{org}` is not a canonical organization id (letters, digits, `.`, `_`, `-`)"
24 ));
25 }
26 Ok(())
27}
28
29#[derive(
32 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
33)]
34#[serde(deny_unknown_fields)]
35pub struct WorkContext {
36 pub api_base: String,
37 pub account_id: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub organization_id: Option<String>,
40}
41
42impl WorkContext {
43 pub fn new(
44 api_base: impl Into<String>,
45 account_id: impl Into<String>,
46 organization_id: Option<String>,
47 ) -> Result<Self, String> {
48 let context = Self {
49 api_base: api_base.into().trim_end_matches('/').to_string(),
50 account_id: account_id.into(),
51 organization_id,
52 };
53 context.validate()?;
54 Ok(context)
55 }
56
57 pub fn storage_key(&self, resource: &str) -> String {
60 use sha2::{Digest, Sha256};
61 let mut hash = Sha256::new();
62 hash.update(b"car-work-context-v1");
63 for value in [
64 self.api_base.as_str(),
65 self.account_id.as_str(),
66 self.organization_id.as_deref().unwrap_or(""),
67 resource,
68 ] {
69 hash.update((value.len() as u64).to_be_bytes());
70 hash.update(value.as_bytes());
71 }
72 format!("work-{:x}", hash.finalize())
73 }
74
75 pub fn validate(&self) -> Result<(), String> {
76 let url = url::Url::parse(&self.api_base).map_err(|_| "invalid credential authority")?;
77 let loopback = url.host_str().is_some_and(|host| {
78 host == "localhost"
79 || host
80 .parse::<std::net::IpAddr>()
81 .is_ok_and(|ip| ip.is_loopback())
82 });
83 if (url.scheme() != "https" && !(url.scheme() == "http" && loopback))
84 || !url.username().is_empty()
85 || url.password().is_some()
86 || url.query().is_some()
87 || url.fragment().is_some()
88 || self.api_base.ends_with('/')
89 || self.account_id.trim() != self.account_id
90 || self.account_id.is_empty()
91 || self.account_id.len() > 256
92 {
93 return Err("credential context requires a canonical authority and account".into());
94 }
95 if self
96 .organization_id
97 .as_deref()
98 .is_some_and(|org| validate_organization_id(org).is_err())
99 {
100 return Err("credential context requires a canonical organization id".into());
101 }
102 Ok(())
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 #[test]
110 fn private_resource_keys_keep_all_ownership_dimensions_separate() {
111 let base =
112 WorkContext::new("https://api.parslee.ai", "matt", Some("parslee".into())).unwrap();
113 let key = base.storage_key("agent:same-id");
114 let mut contexts = Vec::new();
115 let mut other = base.clone();
116 other.organization_id = Some("flyexclusive".into());
117 contexts.push(other);
118 let mut other = base.clone();
119 other.organization_id = None;
120 contexts.push(other);
121 let mut other = base.clone();
122 other.account_id = "colleague".into();
123 contexts.push(other);
124 let mut other = base.clone();
125 other.api_base = "https://other.example".into();
126 contexts.push(other);
127 for other in contexts {
128 assert_ne!(other.storage_key("agent:same-id"), key);
129 }
130 assert_ne!(base.storage_key("agent:other-id"), key);
131 assert_eq!(
132 serde_json::from_str::<WorkContext>(&serde_json::to_string(&base).unwrap())
133 .unwrap()
134 .storage_key("agent:same-id"),
135 key
136 );
137 }
138}