use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use cheers_core::{Scope, StoreError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct BundleName(String);
impl BundleName {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for BundleName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for BundleName {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for BundleName {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum ScopeOrBundle {
Scope(Scope),
Bundle(BundleName),
}
impl From<Scope> for ScopeOrBundle {
fn from(s: Scope) -> Self {
Self::Scope(s)
}
}
impl From<BundleName> for ScopeOrBundle {
fn from(b: BundleName) -> Self {
Self::Bundle(b)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BundleExpansionError {
#[error("unknown bundle '{0}'")]
Unknown(BundleName),
#[error("store: {0}")]
Store(#[from] StoreError),
}
#[async_trait]
pub trait BundleStore: Send + Sync {
async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError>;
async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError>;
async fn delete(&self, name: &BundleName) -> Result<(), StoreError>;
}
pub async fn expand_scopes<S: BundleStore + ?Sized>(
store: &S,
entries: &[ScopeOrBundle],
) -> Result<Vec<Scope>, BundleExpansionError> {
let mut out: Vec<Scope> = Vec::with_capacity(entries.len());
let mut seen: HashSet<Scope> = HashSet::with_capacity(entries.len());
for entry in entries {
match entry {
ScopeOrBundle::Scope(s) => {
if seen.insert(*s) {
out.push(*s);
}
}
ScopeOrBundle::Bundle(name) => {
let scopes = store
.get(name)
.await?
.ok_or_else(|| BundleExpansionError::Unknown(name.clone()))?;
for s in scopes {
if seen.insert(s) {
out.push(s);
}
}
}
}
}
Ok(out)
}
#[derive(Default, Clone)]
pub struct MemoryBundleStore {
inner: Arc<Mutex<BTreeMap<String, Vec<Scope>>>>,
}
impl MemoryBundleStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_defaults() -> Self {
let store = Self::new();
let camp_operator = vec![
Scope::CampRead,
Scope::CampAdmin,
Scope::BoardRead,
Scope::BoardWrite,
Scope::PartyRead,
Scope::PartyWrite,
Scope::SubagentSpawn,
Scope::SubagentControl,
];
let deploy_admin = vec![Scope::CloudRead, Scope::CloudDeploy, Scope::CloudDestroy];
{
let mut g = store.inner.lock().expect("bundle store mutex poisoned");
g.insert("camp-operator".to_owned(), camp_operator);
g.insert("deploy-admin".to_owned(), deploy_admin);
}
store
}
}
#[async_trait]
impl BundleStore for MemoryBundleStore {
async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError> {
Ok(self
.inner
.lock()
.expect("bundle store mutex poisoned")
.get(name.as_str())
.cloned())
}
async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError> {
self.inner
.lock()
.expect("bundle store mutex poisoned")
.insert(name.as_str().to_owned(), scopes.to_vec());
Ok(())
}
async fn delete(&self, name: &BundleName) -> Result<(), StoreError> {
self.inner
.lock()
.expect("bundle store mutex poisoned")
.remove(name.as_str());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::{McpClaims, PrincipalId};
use pollster::block_on;
#[test]
fn memory_store_get_put_delete_roundtrip() {
let store = MemoryBundleStore::new();
let name = BundleName::new("custom");
block_on(async {
assert!(store.get(&name).await.unwrap().is_none());
store.put(&name, &[Scope::CloudRead]).await.unwrap();
assert_eq!(
store.get(&name).await.unwrap(),
Some(vec![Scope::CloudRead])
);
store.delete(&name).await.unwrap();
assert!(store.get(&name).await.unwrap().is_none());
});
}
#[test]
fn with_defaults_seeds_doc_named_bundles() {
let store = MemoryBundleStore::with_defaults();
block_on(async {
assert!(
store
.get(&BundleName::new("camp-operator"))
.await
.unwrap()
.is_some(),
"camp-operator must be seeded"
);
assert!(
store
.get(&BundleName::new("deploy-admin"))
.await
.unwrap()
.is_some(),
"deploy-admin must be seeded"
);
});
}
#[test]
fn expand_literal_scopes_passes_through_in_order_and_dedups() {
let store = MemoryBundleStore::new();
let grants = vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudRead),
];
let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
assert_eq!(expanded, vec![Scope::CloudRead, Scope::CloudDeploy]);
}
#[test]
fn expand_bundle_replaces_name_with_literal_scope_list() {
let store = MemoryBundleStore::with_defaults();
let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
assert_eq!(
expanded,
vec![
Scope::CampRead,
Scope::CampAdmin,
Scope::BoardRead,
Scope::BoardWrite,
Scope::PartyRead,
Scope::PartyWrite,
Scope::SubagentSpawn,
Scope::SubagentControl,
]
);
}
#[test]
fn bundle_edit_propagates_on_next_expand_without_rewriting_grant() {
let store = MemoryBundleStore::with_defaults();
let grants = vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))];
let before = block_on(expand_scopes(&store, &grants)).unwrap();
assert!(before.contains(&Scope::CloudDestroy));
block_on(store.put(
&BundleName::new("deploy-admin"),
&[Scope::CloudRead, Scope::CloudDeploy],
))
.unwrap();
let after = block_on(expand_scopes(&store, &grants)).unwrap();
assert!(!after.contains(&Scope::CloudDestroy));
assert_eq!(after, vec![Scope::CloudRead, Scope::CloudDeploy]);
}
#[test]
fn expand_mixed_scope_and_bundle_dedups_across_both() {
let store = MemoryBundleStore::with_defaults();
let grants = vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Bundle(BundleName::new("deploy-admin")),
];
let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
let count = expanded.iter().filter(|s| **s == Scope::CloudRead).count();
assert_eq!(count, 1);
assert_eq!(expanded[0], Scope::CloudRead);
assert!(expanded.contains(&Scope::CloudDeploy));
assert!(expanded.contains(&Scope::CloudDestroy));
}
#[test]
fn expand_unknown_bundle_is_typed_error() {
let store = MemoryBundleStore::new();
let grants = vec![ScopeOrBundle::Bundle(BundleName::new("does-not-exist"))];
let err = block_on(expand_scopes(&store, &grants)).unwrap_err();
match err {
BundleExpansionError::Unknown(name) => assert_eq!(name.as_str(), "does-not-exist"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn expanded_scopes_serialize_as_wire_strings_no_bundle_name() {
let store = MemoryBundleStore::with_defaults();
let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
let claims = McpClaims::new(
"https://cheers.example",
"https://kamaji.camp.example",
PrincipalId::user("alice"),
1000,
1300,
"jti-1",
expanded,
);
let json = serde_json::to_string(&claims).unwrap();
assert!(
!json.contains("camp-operator"),
"bundle name leaked to wire: {json}"
);
assert!(
json.contains(
r#""scope":["camp:read","camp:admin","board:read","board:write","party:read","party:write","subagent:spawn","subagent:control"]"#
),
"expected literal scope wire list in {json}"
);
let back: McpClaims = serde_json::from_str(&json).unwrap();
assert_eq!(back.scope, claims.scope);
}
#[test]
fn scope_or_bundle_serialize_is_externally_tagged() {
let s = ScopeOrBundle::Scope(Scope::CloudDeploy);
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"scope":"cloud:deploy"}"#);
let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
let b = ScopeOrBundle::Bundle(BundleName::new("camp-operator"));
let json = serde_json::to_string(&b).unwrap();
assert_eq!(json, r#"{"bundle":"camp-operator"}"#);
let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
assert_eq!(back, b);
}
#[test]
fn scope_or_bundle_deserialize_rejects_wildcard_inside_scope_variant() {
let err =
serde_json::from_str::<ScopeOrBundle>(r#"{"scope":"cloud:*"}"#).unwrap_err();
assert!(
err.to_string().contains("wildcard"),
"expected wildcard rejection: {err}"
);
}
}