Return exact matches from the bundle ID filters

App Store Connect's filter[bundleId] on /v1/apps and filter[identifier] on /v1/bundleIds both match substrings, which a stress test across the six Splitfire apps showed: - apps list --bundle-id ai.splitfire.KaroKowe returned KaroKowe Jam (ai.splitfire.KaroKoweJam) first, then KaroKowe. Anything that took the first row resolved the wrong app. - bundle-ids list --identifier ai.splitfire.SplitfireAI returned 7 rows, including .topshelf, -Debug, a Services ID, and ai.splitfire.SplitFireAIWatch through case folding. That makes "is this bundle ID registered?" answer yes for IDs that aren't. The filters still go to the server, and the rows are then cut to exact matches in list_apps and list_bundle_ids, so the CLI and MCP get the fix together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 09:55 UTC 4070819fee4296e35fabbc0a4b819264eaca5d90
2 files changed +87 -6
crates/aso/src/app.rs
+44 -4
@@ -45,9 +45,14 @@ pub struct AppUpdateAttributes {
45 /// to call these on a `Client`.
46 #[async_trait]
47 pub trait AppsApi {
48 - /// `GET /v1/apps`, optionally narrowed with `filter[bundleId]` — the
49 - /// usual way to resolve an app's ASC id from the bundle identifier
50 - /// already baked into an Xcode project.
48 + /// `GET /v1/apps`, optionally narrowed to the app with exactly this
49 + /// bundle identifier — the usual way to resolve an app's ASC id from
50 + /// the bundle identifier already baked into an Xcode project.
51 + ///
52 + /// App Store Connect's `filter[bundleId]` matches substrings, so
53 + /// `ai.splitfire.KaroKowe` also returns `ai.splitfire.KaroKoweJam`, and
54 + /// listed first. The filter still goes to the server to keep the
55 + /// response small, and the rows are then cut to exact matches here.
56 async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result<Vec<App>>;
57
58 async fn get_app(&self, app_id: &str) -> Result<App>;
@@ -62,7 +67,11 @@ impl AppsApi for Client {
67 if let Some(bundle_id) = filter_bundle_id {
68 query.push(("filter[bundleId]", bundle_id));
69 }
65 - self.list_all::<AppAttributes>("/v1/apps", &query).await
70 + let apps = self.list_all::<AppAttributes>("/v1/apps", &query).await?;
71 + Ok(match filter_bundle_id {
72 + Some(bundle_id) => exact_bundle_id(apps, bundle_id),
73 + None => apps,
74 + })
75 }
76
77 async fn get_app(&self, app_id: &str) -> Result<App> {
@@ -86,3 +95,34 @@ impl AppsApi for Client {
95 Ok(doc.data)
96 }
97 }
98 +
99 +/// Keep the apps whose bundle identifier is exactly `bundle_id`.
100 +fn exact_bundle_id(apps: Vec<App>, bundle_id: &str) -> Vec<App> {
101 + apps.into_iter()
102 + .filter(|app| app.attributes.bundle_id.as_deref() == Some(bundle_id))
103 + .collect()
104 +}
105 +
106 +#[cfg(test)]
107 +mod tests {
108 + use super::*;
109 + use smbcloud_ascapi_core::jsonapi::ListDocument;
110 +
111 + /// The shape App Store Connect returned for `filter[bundleId]=
112 + /// ai.splitfire.KaroKowe`: the longer bundle ID first.
113 + #[test]
114 + fn a_bundle_id_filter_keeps_only_the_exact_match() {
115 + let doc: ListDocument<AppAttributes> = serde_json::from_value(serde_json::json!({
116 + "data": [
117 + { "id": "6787445875", "type": "apps",
118 + "attributes": { "bundleId": "ai.splitfire.KaroKoweJam", "name": "KaroKowe Jam" } },
119 + { "id": "6756801861", "type": "apps",
120 + "attributes": { "bundleId": "ai.splitfire.KaroKowe", "name": "Karaoke KaroKowe" } }
121 + ]
122 + }))
123 + .unwrap();
124 + let apps = exact_bundle_id(doc.data, "ai.splitfire.KaroKowe");
125 + assert_eq!(apps.len(), 1);
126 + assert_eq!(apps[0].id, "6756801861");
127 + }
128 +}
crates/aso/src/bundle_id.rs
+43 -2
@@ -65,6 +65,12 @@ pub trait BundleIdsApi {
65 /// check whether a bundle ID is already registered before trying to
66 /// create it, or before creating an App Store Version under an app
67 /// that uses it.
68 + ///
69 + /// App Store Connect's `filter[identifier]` is a case-insensitive
70 + /// substring match: `ai.splitfire.SplitfireAI` also returns
71 + /// `….topshelf`, `-Debug` and `ai.splitfire.SplitFireAIWatch`. The rows
72 + /// are cut to the exact identifier here, so an empty result really
73 + /// means "not registered".
74 async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>>;
75
76 /// `POST /v1/bundleIds` — registers a new bundle ID with the developer
@@ -84,8 +90,13 @@ impl BundleIdsApi for Client {
90 // truncate the result.
91 query.push(("limit", "200"));
92
87 - self.list_all::<BundleIdAttributes>("/v1/bundleIds", &query)
88 - .await
93 + let bundle_ids = self
94 + .list_all::<BundleIdAttributes>("/v1/bundleIds", &query)
95 + .await?;
96 + Ok(match filter_identifier {
97 + Some(identifier) => exact_identifier(bundle_ids, identifier),
98 + None => bundle_ids,
99 + })
100 }
101
102 async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId> {
@@ -103,11 +114,41 @@ impl BundleIdsApi for Client {
114 }
115 }
116
117 +/// Keep the bundle IDs whose identifier is exactly `identifier`.
118 +fn exact_identifier(bundle_ids: Vec<BundleId>, identifier: &str) -> Vec<BundleId> {
119 + bundle_ids
120 + .into_iter()
121 + .filter(|bundle_id| bundle_id.attributes.identifier.as_deref() == Some(identifier))
122 + .collect()
123 +}
124 +
125 #[cfg(test)]
126 mod tests {
127 use super::*;
128 use smbcloud_ascapi_core::jsonapi::ListDocument;
129
130 + /// Trimmed from what `filter[identifier]=ai.splitfire.SplitfireAI`
131 + /// actually returned: neighbours, a Services ID, and a different app
132 + /// matched through case folding.
133 + #[test]
134 + fn an_identifier_filter_keeps_only_the_exact_match() {
135 + let doc: ListDocument<BundleIdAttributes> = serde_json::from_value(serde_json::json!({
136 + "data": [
137 + { "id": "A", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAIAuth", "platform": "SERVICES" } },
138 + { "id": "B", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI", "platform": "UNIVERSAL" } },
139 + { "id": "C", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI.topshelf", "platform": "UNIVERSAL" } },
140 + { "id": "D", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitFireAIWatch", "platform": "UNIVERSAL" } }
141 + ]
142 + }))
143 + .unwrap();
144 + let exact = exact_identifier(doc.data.clone(), "ai.splitfire.SplitfireAI");
145 + assert_eq!(
146 + exact.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(),
147 + ["B"]
148 + );
149 + assert!(exact_identifier(doc.data, "ai.splitfire.Splitfire").is_empty());
150 + }
151 +
152 #[test]
153 fn a_services_identifier_does_not_break_the_list() {
154 // Regression: `GET /v1/bundleIds` returns every identifier a team