Show each profile's bundle ID with profiles list --with-bundle-id

A profile's bundle ID is a relationship, so a list response has a link but no identifier. Telling which app an expired profile belongs to, and whether a newer valid profile already covers that bundle, meant opening each one in the web UI. ProfilesApi::get_profile_bundle_id reads GET /v1/profiles/{id}/bundleId, and profiles list --with-bundle-id adds bundle_id and bundle_identifier to each row. It costs one request per profile, so it only runs when asked. Both fields are left out of the JSON when unset, which keeps the MCP tool output unchanged. This found 19 of 23 Mac App Store bundles with no valid profile (all expired 2026-08-16). They were renewed with profiles create the same day. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 11:10 UTC be95f37166dcb8cb3ffce354389abd7f392130c4
3 files changed +78 -2
crates/cli/src/main.rs
+17 -2
@@ -183,6 +183,10 @@ enum ProfilesCommand {
183 /// Only show one type.
184 #[arg(long, value_enum)]
185 r#type: Option<CliProfileType>,
186 + /// Add each profile's bundle ID (`bundle_id`, `bundle_identifier`).
187 + /// One extra request per profile.
188 + #[arg(long)]
189 + with_bundle_id: bool,
190 },
191 /// Create a profile over a bundle ID and one or more certificates.
192 ///
@@ -1506,7 +1510,11 @@ async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool)
1510 use smbcloud_ascapi_frontend::time::now_iso8601;
1511
1512 match command {
1509 - ProfilesCommand::List { name, r#type } => {
1513 + ProfilesCommand::List {
1514 + name,
1515 + r#type,
1516 + with_bundle_id,
1517 + } => {
1518 let mut profiles = client
1519 .list_profiles(name.as_deref(), r#type.map(Into::into))
1520 .await?;
@@ -1516,10 +1524,17 @@ async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool)
1524 .cmp(&b.attributes.expiration_date)
1525 });
1526 let now = now_iso8601();
1519 - let summaries: Vec<_> = profiles
1527 + let mut summaries: Vec<_> = profiles
1528 .iter()
1529 .map(|profile| ProfileSummary::from_resource(profile, &now))
1530 .collect();
1531 + if with_bundle_id {
1532 + for summary in &mut summaries {
1533 + let bundle = client.get_profile_bundle_id(&summary.id).await?;
1534 + summary.bundle_identifier = bundle.attributes.identifier;
1535 + summary.bundle_id = Some(bundle.id);
1536 + }
1537 + }
1538 print_json(&summaries)
1539 }
1540
crates/frontend/src/profiles.rs
+15
@@ -61,6 +61,15 @@ pub struct ProfileSummary {
61 /// profile can also be unusable while unexpired if its certificate was
62 /// revoked — check `profile_state` for that case.
63 pub expired: Option<bool>,
64 + /// The bundle ID's resource id, set only when the caller asked for it
65 + /// (`profiles list --with-bundle-id`), since it costs a request per
66 + /// profile.
67 + #[serde(skip_serializing_if = "Option::is_none")]
68 + pub bundle_id: Option<String>,
69 + /// The bundle identifier itself, e.g. `ai.splitfire.SplitfireAI`. Set
70 + /// alongside `bundle_id`.
71 + #[serde(skip_serializing_if = "Option::is_none")]
72 + pub bundle_identifier: Option<String>,
73 }
74
75 impl ProfileSummary {
@@ -86,6 +95,8 @@ impl ProfileSummary {
95 created_date: profile.attributes.created_date.clone(),
96 expiration_date,
97 expired,
98 + bundle_id: None,
99 + bundle_identifier: None,
100 }
101 }
102 }
@@ -185,6 +196,8 @@ mod tests {
196 created_date: Some("2026-08-26T20:03:07".to_string()),
197 expiration_date: Some("2027-08-06T19:49:55".to_string()),
198 expired: Some(false),
199 + bundle_id: None,
200 + bundle_identifier: None,
201 };
202 let json = serde_json::to_string(&summary).expect("serializes");
203 assert!(!json.contains("profileContent"));
@@ -204,6 +217,8 @@ mod tests {
217 created_date: None,
218 expiration_date: None,
219 expired: None,
220 + bundle_id: None,
221 + bundle_identifier: None,
222 },
223 path: "/tmp/out/app.provisionprofile".to_string(),
224 bytes: 12577,
crates/signing/src/profile.rs
+46
@@ -182,6 +182,18 @@ pub struct ProfileCreateAttributes {
182 pub profile_type: ProfileType,
183 }
184
185 +/// The bundle ID behind a profile, as `GET /v1/profiles/{id}/bundleId`
186 +/// returns it. Only the fields a caller needs to match a profile to an
187 +/// app; the signing crate doesn't depend on the aso crate's full type.
188 +#[derive(Debug, Clone, Serialize, Deserialize)]
189 +#[serde(rename_all = "camelCase")]
190 +pub struct ProfileBundleIdAttributes {
191 + pub identifier: Option<String>,
192 + pub platform: Option<String>,
193 +}
194 +
195 +pub type ProfileBundleId = Resource<ProfileBundleIdAttributes>;
196 +
197 /// The relationships a create request must carry.
198 ///
199 /// Built explicitly rather than derived from ids alone, because `devices`
@@ -281,6 +293,13 @@ pub trait ProfilesApi {
293 relationships: ProfileCreateRelationships,
294 ) -> Result<Profile>;
295
296 + /// `GET /v1/profiles/{id}/bundleId` — the bundle ID a profile is for.
297 + ///
298 + /// A list response carries only the relationship link, not the
299 + /// identifier, so finding which app an expired profile belongs to
300 + /// takes one of these per profile.
301 + async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId>;
302 +
303 /// `DELETE /v1/profiles/{id}`.
304 ///
305 /// Narrower than revoking a certificate: it invalidates only this
@@ -346,6 +365,18 @@ impl ProfilesApi for Client {
365 Ok(doc.data)
366 }
367
368 + async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId> {
369 + let doc: Document<ProfileBundleIdAttributes> = self
370 + .request(
371 + Method::GET,
372 + &format!("/v1/profiles/{id}/bundleId"),
373 + &[],
374 + None::<&()>,
375 + )
376 + .await?;
377 + Ok(doc.data)
378 + }
379 +
380 async fn delete_profile(&self, id: &str) -> Result<()> {
381 self.request_no_content(
382 Method::DELETE,
@@ -451,4 +482,19 @@ mod tests {
482 let json = serde_json::to_value(&relationships).expect("serializes");
483 assert!(json.get("devices").is_none());
484 }
485 +
486 + #[test]
487 + fn reads_the_bundle_id_behind_a_profile() {
488 + let doc: Document<ProfileBundleIdAttributes> = serde_json::from_value(serde_json::json!({
489 + "data": { "id": "C3PP53MLM3", "type": "bundleIds",
490 + "attributes": { "identifier": "ai.splitfire.SplitfireAI",
491 + "platform": "UNIVERSAL", "name": "SplitFire AI", "seedId": "2TQF86ZACD" } }
492 + }))
493 + .unwrap();
494 + assert_eq!(doc.data.id, "C3PP53MLM3");
495 + assert_eq!(
496 + doc.data.attributes.identifier.as_deref(),
497 + Some("ai.splitfire.SplitfireAI")
498 + );
499 + }
500 }