1use std::collections::{BTreeMap, HashSet};
26use std::sync::{Arc, Mutex};
27
28use async_trait::async_trait;
29use cheers_core::{Scope, StoreError};
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(transparent)]
38pub struct BundleName(String);
39
40impl BundleName {
41 pub fn new(name: impl Into<String>) -> Self {
42 Self(name.into())
43 }
44
45 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48}
49
50impl std::fmt::Display for BundleName {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(&self.0)
53 }
54}
55
56impl From<&str> for BundleName {
57 fn from(s: &str) -> Self {
58 Self::new(s)
59 }
60}
61
62impl From<String> for BundleName {
63 fn from(s: String) -> Self {
64 Self(s)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[non_exhaustive]
77#[serde(rename_all = "snake_case")]
78pub enum ScopeOrBundle {
79 Scope(Scope),
80 Bundle(BundleName),
81}
82
83impl From<Scope> for ScopeOrBundle {
84 fn from(s: Scope) -> Self {
85 Self::Scope(s)
86 }
87}
88
89impl From<BundleName> for ScopeOrBundle {
90 fn from(b: BundleName) -> Self {
91 Self::Bundle(b)
92 }
93}
94
95#[derive(Debug, thiserror::Error)]
97#[non_exhaustive]
98pub enum BundleExpansionError {
99 #[error("unknown bundle '{0}'")]
102 Unknown(BundleName),
103 #[error("store: {0}")]
105 Store(#[from] StoreError),
106}
107
108#[async_trait]
113pub trait BundleStore: Send + Sync {
114 async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError>;
116
117 async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError>;
119
120 async fn delete(&self, name: &BundleName) -> Result<(), StoreError>;
124}
125
126pub async fn expand_scopes<S: BundleStore + ?Sized>(
134 store: &S,
135 entries: &[ScopeOrBundle],
136) -> Result<Vec<Scope>, BundleExpansionError> {
137 let mut out: Vec<Scope> = Vec::with_capacity(entries.len());
138 let mut seen: HashSet<Scope> = HashSet::with_capacity(entries.len());
139
140 for entry in entries {
141 match entry {
142 ScopeOrBundle::Scope(s) => {
143 if seen.insert(*s) {
144 out.push(*s);
145 }
146 }
147 ScopeOrBundle::Bundle(name) => {
148 let scopes = store
149 .get(name)
150 .await?
151 .ok_or_else(|| BundleExpansionError::Unknown(name.clone()))?;
152 for s in scopes {
153 if seen.insert(s) {
154 out.push(s);
155 }
156 }
157 }
158 }
159 }
160 Ok(out)
161}
162
163#[derive(Default, Clone)]
166pub struct MemoryBundleStore {
167 inner: Arc<Mutex<BTreeMap<String, Vec<Scope>>>>,
168}
169
170impl MemoryBundleStore {
171 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn with_defaults() -> Self {
181 let store = Self::new();
182 let camp_operator = vec![
183 Scope::CampRead,
184 Scope::CampAdmin,
185 Scope::BoardRead,
186 Scope::BoardWrite,
187 Scope::PartyRead,
188 Scope::PartyWrite,
189 Scope::SubagentSpawn,
190 Scope::SubagentControl,
191 ];
192 let deploy_admin = vec![Scope::CloudRead, Scope::CloudDeploy, Scope::CloudDestroy];
193 {
194 let mut g = store.inner.lock().expect("bundle store mutex poisoned");
195 g.insert("camp-operator".to_owned(), camp_operator);
196 g.insert("deploy-admin".to_owned(), deploy_admin);
197 }
198 store
199 }
200}
201
202#[async_trait]
203impl BundleStore for MemoryBundleStore {
204 async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError> {
205 Ok(self
206 .inner
207 .lock()
208 .expect("bundle store mutex poisoned")
209 .get(name.as_str())
210 .cloned())
211 }
212
213 async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError> {
214 self.inner
215 .lock()
216 .expect("bundle store mutex poisoned")
217 .insert(name.as_str().to_owned(), scopes.to_vec());
218 Ok(())
219 }
220
221 async fn delete(&self, name: &BundleName) -> Result<(), StoreError> {
222 self.inner
223 .lock()
224 .expect("bundle store mutex poisoned")
225 .remove(name.as_str());
226 Ok(())
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use cheers_core::{McpClaims, PrincipalId};
234 use pollster::block_on;
235
236 #[test]
237 fn memory_store_get_put_delete_roundtrip() {
238 let store = MemoryBundleStore::new();
239 let name = BundleName::new("custom");
240 block_on(async {
241 assert!(store.get(&name).await.unwrap().is_none());
242 store.put(&name, &[Scope::CloudRead]).await.unwrap();
243 assert_eq!(
244 store.get(&name).await.unwrap(),
245 Some(vec![Scope::CloudRead])
246 );
247 store.delete(&name).await.unwrap();
248 assert!(store.get(&name).await.unwrap().is_none());
249 });
250 }
251
252 #[test]
253 fn with_defaults_seeds_doc_named_bundles() {
254 let store = MemoryBundleStore::with_defaults();
255 block_on(async {
256 assert!(
257 store
258 .get(&BundleName::new("camp-operator"))
259 .await
260 .unwrap()
261 .is_some(),
262 "camp-operator must be seeded"
263 );
264 assert!(
265 store
266 .get(&BundleName::new("deploy-admin"))
267 .await
268 .unwrap()
269 .is_some(),
270 "deploy-admin must be seeded"
271 );
272 });
273 }
274
275 #[test]
276 fn expand_literal_scopes_passes_through_in_order_and_dedups() {
277 let store = MemoryBundleStore::new();
278 let grants = vec![
279 ScopeOrBundle::Scope(Scope::CloudRead),
280 ScopeOrBundle::Scope(Scope::CloudDeploy),
281 ScopeOrBundle::Scope(Scope::CloudRead),
282 ];
283 let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
284 assert_eq!(expanded, vec![Scope::CloudRead, Scope::CloudDeploy]);
285 }
286
287 #[test]
288 fn expand_bundle_replaces_name_with_literal_scope_list() {
289 let store = MemoryBundleStore::with_defaults();
292 let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
293 let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
294 assert_eq!(
295 expanded,
296 vec![
297 Scope::CampRead,
298 Scope::CampAdmin,
299 Scope::BoardRead,
300 Scope::BoardWrite,
301 Scope::PartyRead,
302 Scope::PartyWrite,
303 Scope::SubagentSpawn,
304 Scope::SubagentControl,
305 ]
306 );
307 }
308
309 #[test]
310 fn bundle_edit_propagates_on_next_expand_without_rewriting_grant() {
311 let store = MemoryBundleStore::with_defaults();
314 let grants = vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))];
315
316 let before = block_on(expand_scopes(&store, &grants)).unwrap();
317 assert!(before.contains(&Scope::CloudDestroy));
318
319 block_on(store.put(
321 &BundleName::new("deploy-admin"),
322 &[Scope::CloudRead, Scope::CloudDeploy],
323 ))
324 .unwrap();
325
326 let after = block_on(expand_scopes(&store, &grants)).unwrap();
327 assert!(!after.contains(&Scope::CloudDestroy));
328 assert_eq!(after, vec![Scope::CloudRead, Scope::CloudDeploy]);
329 }
330
331 #[test]
332 fn expand_mixed_scope_and_bundle_dedups_across_both() {
333 let store = MemoryBundleStore::with_defaults();
334 let grants = vec![
335 ScopeOrBundle::Scope(Scope::CloudRead),
336 ScopeOrBundle::Bundle(BundleName::new("deploy-admin")),
337 ];
338 let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
339 let count = expanded.iter().filter(|s| **s == Scope::CloudRead).count();
341 assert_eq!(count, 1);
342 assert_eq!(expanded[0], Scope::CloudRead);
343 assert!(expanded.contains(&Scope::CloudDeploy));
344 assert!(expanded.contains(&Scope::CloudDestroy));
345 }
346
347 #[test]
348 fn expand_unknown_bundle_is_typed_error() {
349 let store = MemoryBundleStore::new();
350 let grants = vec![ScopeOrBundle::Bundle(BundleName::new("does-not-exist"))];
351 let err = block_on(expand_scopes(&store, &grants)).unwrap_err();
352 match err {
353 BundleExpansionError::Unknown(name) => assert_eq!(name.as_str(), "does-not-exist"),
354 other => panic!("expected Unknown, got {other:?}"),
355 }
356 }
357
358 #[test]
359 fn expanded_scopes_serialize_as_wire_strings_no_bundle_name() {
360 let store = MemoryBundleStore::with_defaults();
365 let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
366 let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
367
368 let claims = McpClaims::new(
369 "https://cheers.example",
370 "https://kamaji.camp.example",
371 PrincipalId::user("alice"),
372 1000,
373 1300,
374 "jti-1",
375 expanded,
376 );
377 let json = serde_json::to_string(&claims).unwrap();
378 assert!(
379 !json.contains("camp-operator"),
380 "bundle name leaked to wire: {json}"
381 );
382 assert!(
383 json.contains(
384 r#""scope":["camp:read","camp:admin","board:read","board:write","party:read","party:write","subagent:spawn","subagent:control"]"#
385 ),
386 "expected literal scope wire list in {json}"
387 );
388 let back: McpClaims = serde_json::from_str(&json).unwrap();
389 assert_eq!(back.scope, claims.scope);
390 }
391
392 #[test]
393 fn scope_or_bundle_serialize_is_externally_tagged() {
394 let s = ScopeOrBundle::Scope(Scope::CloudDeploy);
395 let json = serde_json::to_string(&s).unwrap();
396 assert_eq!(json, r#"{"scope":"cloud:deploy"}"#);
397 let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
398 assert_eq!(back, s);
399
400 let b = ScopeOrBundle::Bundle(BundleName::new("camp-operator"));
401 let json = serde_json::to_string(&b).unwrap();
402 assert_eq!(json, r#"{"bundle":"camp-operator"}"#);
403 let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
404 assert_eq!(back, b);
405 }
406
407 #[test]
408 fn scope_or_bundle_deserialize_rejects_wildcard_inside_scope_variant() {
409 let err =
412 serde_json::from_str::<ScopeOrBundle>(r#"{"scope":"cloud:*"}"#).unwrap_err();
413 assert!(
414 err.to_string().contains("wildcard"),
415 "expected wildcard rejection: {err}"
416 );
417 }
418}