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