use car_proto::ParsleeIdentity;
use serde_json::{json, Value};
use crate::parslee_auth::{self, ParsleeSession};
const DEFAULT_STUDIO_BASE: &str = "https://studio.parslee.ai";
const STUDIO_BASE_ENV: &str = "PARSLEE_STUDIO_BASE";
#[derive(Debug, Clone)]
struct StudioProbe {
reachable: bool,
bearer_accepted: Option<bool>,
status: Option<u16>,
note: String,
}
pub(crate) fn api_base() -> String {
car_secrets::resolve_env_or_keychain(parslee_auth::API_BASE_KEY)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| parslee_auth::DEFAULT_API_BASE.to_string())
}
pub(crate) struct Gate {
pub session: ParsleeSession,
pub org_id: String,
pub api_base: String,
}
pub(crate) async fn gate_on_product(product: &str) -> Result<Gate, String> {
let session = parslee_auth::load_or_refresh()
.await?
.ok_or_else(|| {
"not signed in to Parslee — run `car auth login` (or sign in via CAR Host.app)"
.to_string()
})?;
let org_id = session
.identity
.active_organization
.clone()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
"the signed-in account has no active organization — finish onboarding via \
CAR Host.app or the web"
.to_string()
})?;
let base = api_base();
let client = reqwest::Client::new();
let products = enabled_products(&client, &base, &session.access_token, &org_id).await?;
if !products.iter().any(|p| p.eq_ignore_ascii_case(product)) {
return Err(format!(
"the '{product}' product is not enabled for this organization \
(enabled: {}). Enable it in Parslee, then retry — see \
`car parslee capabilities`.",
if products.is_empty() {
"none".to_string()
} else {
products.join(", ")
}
));
}
Ok(Gate {
session,
org_id,
api_base: base,
})
}
pub(crate) async fn enabled_products(
client: &reqwest::Client,
base: &str,
bearer: &str,
org_id: &str,
) -> Result<Vec<String>, String> {
let summary = fetch_entitlements(client, base, bearer, org_id).await?;
Ok(summary["enabled_products"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
fn studio_base() -> String {
car_secrets::resolve_env_or_keychain(STUDIO_BASE_ENV)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_STUDIO_BASE.to_string())
}
pub async fn discover() -> Result<Value, String> {
let session = match parslee_auth::load_or_refresh().await {
Ok(Some(session)) => session,
Ok(None) => {
return Ok(json!({
"authenticated": false,
"hint": "no Parslee account signed in — run `car auth login` \
or sign in via CAR Host.app",
}));
}
Err(reason) => {
return Ok(json!({
"authenticated": false,
"hint": "Parslee session could not be established — run `car auth login` \
to re-authenticate (or sign in via CAR Host.app)",
"error": reason,
}));
}
};
let bearer = session.access_token.as_str();
let identity = &session.identity;
let base = api_base();
let studio_base = studio_base();
let org_id = identity
.active_organization
.as_deref()
.filter(|s| !s.is_empty());
let client = reqwest::Client::new();
let entitlements = match org_id {
Some(org) => fetch_entitlements(&client, &base, bearer, org).await,
None => Err("the signed-in account has no active organization — finish \
onboarding via CAR Host.app or the web"
.to_string()),
};
let studio = probe_studio(&client, &studio_base, bearer, org_id).await;
Ok(build_capabilities(identity, entitlements, &studio, &studio_base))
}
fn build_capabilities(
identity: &ParsleeIdentity,
entitlements: Result<Value, String>,
studio: &StudioProbe,
studio_base: &str,
) -> Value {
let (entitlements_value, entitlements_error) = match entitlements {
Ok(v) => (v, Value::Null),
Err(e) => (Value::Null, json!(e)),
};
json!({
"authenticated": true,
"identity": {
"account_id": identity.account_id,
"email": identity.email,
"display_name": identity.display_name,
"active_organization": identity.active_organization,
"organization_name": identity.organization_name,
},
"entitlements": entitlements_value,
"entitlements_error": entitlements_error,
"studio": {
"host": studio_base,
"reachable": studio.reachable,
"bearer_accepted": studio.bearer_accepted,
"probe_status": studio.status,
"note": studio.note,
},
})
}
pub(crate) async fn fetch_entitlements(
client: &reqwest::Client,
base: &str,
bearer: &str,
org_id: &str,
) -> Result<Value, String> {
let url = format!(
"{}/api/v1/orgs/{}/entitlements",
base.trim_end_matches('/'),
org_id
);
let resp = client
.get(&url)
.bearer_auth(bearer)
.send()
.await
.map_err(|e| format!("Parslee entitlements request: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Parslee entitlements: HTTP {status}: {body}"));
}
let raw: Value = resp
.json()
.await
.map_err(|e| format!("parse Parslee entitlements: {e}"))?;
Ok(normalize_entitlements(&raw, org_id))
}
fn normalize_entitlements(raw: &Value, org_id: &str) -> Value {
let products: Vec<Value> = ci(raw, "entitlements")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.map(|e| {
json!({
"product_id": ci(e, "productId").or_else(|| ci(e, "product_id")).cloned(),
"enabled": ci(e, "enabled").and_then(Value::as_bool).unwrap_or(false),
"tier": ci(e, "tier").cloned(),
"quotas": ci(e, "quotas").cloned(),
})
})
.collect()
})
.unwrap_or_default();
let enabled_products = ci(raw, "enabledProducts")
.or_else(|| ci(raw, "enabled_products"))
.cloned()
.unwrap_or_else(|| json!([]));
json!({
"organization_id": ci(raw, "organizationId")
.and_then(Value::as_str)
.unwrap_or(org_id),
"enabled_products": enabled_products,
"products": products,
})
}
pub(crate) fn ci<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
let obj = v.as_object()?;
if let Some(found) = obj.get(key) {
return Some(found);
}
obj.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, val)| val)
}
async fn probe_studio(
client: &reqwest::Client,
studio_base: &str,
bearer: &str,
org_id: Option<&str>,
) -> StudioProbe {
let base = studio_base.trim_end_matches('/');
let url = match org_id {
Some(org) => format!("{base}/api/v1/orgs/{org}/studio/quota/me"),
None => format!("{base}/health"),
};
let mut req = client.get(&url);
if org_id.is_some() {
req = req.bearer_auth(bearer);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
let code = status.as_u16();
let bearer_accepted = if org_id.is_none() {
None
} else if code == 401 {
Some(false)
} else {
Some(true)
};
let note = match (org_id.is_some(), code) {
(true, 401) => "Studio rejected the bearer — the token→Studio→m365 \
auth chain is not accepting this token".to_string(),
(true, 403) => "Studio accepted the bearer; this account has no Studio \
access in the active organization".to_string(),
(true, c) if (200..300).contains(&c) => {
"Studio reachable and the bearer is accepted".to_string()
}
(true, c) => format!("Studio reachable; unexpected status {c}"),
(false, c) if (200..300).contains(&c) => {
"Studio reachable (health only — no active org to probe authed access)"
.to_string()
}
(false, c) => format!("Studio health probe returned {c}"),
};
StudioProbe {
reachable: true,
bearer_accepted,
status: Some(code),
note,
}
}
Err(e) => StudioProbe {
reachable: false,
bearer_accepted: None,
status: None,
note: format!("Studio unreachable at {base}: {e}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity() -> ParsleeIdentity {
ParsleeIdentity {
account_id: "acc_123".into(),
email: Some("user@example.com".into()),
display_name: Some("Studio User".into()),
active_organization: Some("org_456".into()),
organization_name: Some("Studio Org".into()),
}
}
fn probe_ok() -> StudioProbe {
StudioProbe {
reachable: true,
bearer_accepted: Some(true),
status: Some(200),
note: "ok".into(),
}
}
#[test]
fn normalizes_entitlements_camel_and_pascal() {
let camel = json!({
"organizationId": "org_456",
"enabledProducts": ["studio", "aie"],
"entitlements": [
{ "productId": "studio", "enabled": true, "tier": "Pro", "quotas": {"videos": 10} },
{ "productId": "crm", "enabled": false, "tier": "Standard" }
]
});
let n = normalize_entitlements(&camel, "org_456");
assert_eq!(n["organization_id"], "org_456");
assert_eq!(n["enabled_products"], json!(["studio", "aie"]));
assert_eq!(n["products"][0]["product_id"], "studio");
assert_eq!(n["products"][0]["enabled"], true);
assert_eq!(n["products"][0]["tier"], "Pro");
assert_eq!(n["products"][0]["quotas"]["videos"], 10);
assert_eq!(n["products"][1]["enabled"], false);
let pascal = json!({
"OrganizationId": "org_456",
"EnabledProducts": ["studio"],
"Entitlements": [ { "ProductId": "studio", "Enabled": true, "Tier": "Pro" } ]
});
let np = normalize_entitlements(&pascal, "org_456");
assert_eq!(np["organization_id"], "org_456");
assert_eq!(np["products"][0]["product_id"], "studio");
assert_eq!(np["products"][0]["enabled"], true);
}
#[test]
fn build_capabilities_surfaces_identity_and_studio() {
let ent = Ok(normalize_entitlements(
&json!({"organizationId":"org_456","enabledProducts":["studio"],"entitlements":[]}),
"org_456",
));
let v = build_capabilities(&identity(), ent, &probe_ok(), DEFAULT_STUDIO_BASE);
assert_eq!(v["authenticated"], true);
assert_eq!(v["identity"]["account_id"], "acc_123");
assert_eq!(v["identity"]["active_organization"], "org_456");
assert_eq!(v["entitlements"]["enabled_products"], json!(["studio"]));
assert_eq!(v["entitlements_error"], Value::Null);
assert_eq!(v["studio"]["host"], DEFAULT_STUDIO_BASE);
assert_eq!(v["studio"]["bearer_accepted"], true);
assert_eq!(v["studio"]["probe_status"], 200);
}
#[test]
fn build_capabilities_reports_entitlements_error_without_failing() {
let v = build_capabilities(
&identity(),
Err("HTTP 403: forbidden".into()),
&probe_ok(),
DEFAULT_STUDIO_BASE,
);
assert_eq!(v["authenticated"], true);
assert_eq!(v["entitlements"], Value::Null);
assert_eq!(v["entitlements_error"], "HTTP 403: forbidden");
assert_eq!(v["studio"]["reachable"], true);
}
}