car-server-core 0.25.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! `parslee.capabilities` — read-only discovery of what the signed-in Parslee
//! account can do.
//!
//! Resolves the user's Parslee OAuth bearer (the same token CAR uses for
//! Parslee inference), then:
//!   - reads the account identity (id / email / active organization),
//!   - fetches the org's product entitlements from m365
//!     (`/api/v1/orgs/{orgId}/entitlements`), and
//!   - probes Studio (`studio.parslee.ai`) with the bearer to confirm the
//!     platform is reachable and accepts our token *right now*.
//!
//! It is the foundation every other Parslee capability tool gates on: a CAR
//! agent or the host UI calls this first to learn which products (studio, aie,
//! odi, crm, …) are enabled before offering a creative/document action. It
//! performs no writes and starts no jobs.
//!
//! Studio bearer auth shipped 2026-06-13 (studio `BearerSessionAuthenticationHandler`);
//! the probe here is what confirms that chain is live for this account.

use car_proto::ParsleeIdentity;
use serde_json::{json, Value};

use crate::parslee_auth::{self, ParsleeSession};

/// Default Studio host. Front Door stamps `X-Azure-FDID`; the raw container
/// FQDN is origin-guarded, so callers must use this host, not the container app.
const DEFAULT_STUDIO_BASE: &str = "https://studio.parslee.ai";
const STUDIO_BASE_ENV: &str = "PARSLEE_STUDIO_BASE";

/// Result of probing Studio with the user's bearer.
#[derive(Debug, Clone)]
struct StudioProbe {
    reachable: bool,
    /// `Some(true)` = an authenticated Studio endpoint accepted the bearer;
    /// `Some(false)` = it rejected it (401); `None` = couldn't determine
    /// (network error, or no active org to scope the probe).
    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())
}

/// A passed entitlement gate: the signed-in session plus the resolved active
/// org and API base, ready for a product API call. Returned by
/// [`gate_on_product`] so action tools (document generation, …) fail fast
/// before a doomed request.
pub(crate) struct Gate {
    pub session: ParsleeSession,
    pub org_id: String,
    pub api_base: String,
}

/// Resolve the Parslee session and assert `product` is enabled for the active
/// org. Returns a clear, user-facing error when not signed in, when the account
/// has no active org, or when the product isn't entitled — so capability tools
/// refuse early instead of making a request that would 403 (or silently cost
/// quota). The product id is one of [`car_proto`]-side well-knowns (`aie`,
/// `studio`, `odi`, …).
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,
    })
}

/// The org's enabled product ids (lowercased product slugs), via
/// [`fetch_entitlements`].
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())
}

/// Handler for the `parslee.capabilities` JSON-RPC method.
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",
            }));
        }
        // A stored-but-unusable session (e.g. an expired refresh token) is the
        // same user-facing situation as "not signed in": re-authenticate. Report
        // it gracefully instead of surfacing a raw HTTP/RPC error, but keep the
        // underlying reason so transient failures are still diagnosable.
        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))
}

/// Assemble the capability summary. Pure (no I/O) so it can be unit-tested:
/// `entitlements` is the result of the m365 call (Ok = normalized summary,
/// Err = a human-readable reason it's unavailable).
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,
        },
    })
}

/// GET `/api/v1/orgs/{orgId}/entitlements` → normalized
/// `{ organization_id, enabled_products, products: [{product_id, enabled, tier, quotas}] }`.
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))
}

/// Normalize the m365 `OrganizationEntitlementSummary` to a stable shape,
/// tolerant of camelCase/PascalCase field casing.
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,
    })
}

/// Case-insensitive single-key lookup on a JSON object.
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)
}

/// Probe Studio. With an active org we hit an authenticated endpoint
/// (`/api/v1/orgs/{org}/studio/quota/me`) so the status distinguishes
/// "bearer accepted" (2xx/403) from "bearer rejected" (401). Without an org
/// we fall back to `/health` (reachability only).
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();
            // 401 from the authed probe = the bearer chain is broken
            // (token → Studio Bearer handler → m365 /connect/session).
            // 403 = bearer accepted but this account lacks Studio access here.
            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);

        // PascalCase variant resolves identically.
        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");
        // Studio probe still reported.
        assert_eq!(v["studio"]["reachable"], true);
    }
}