From b2f2fc91f196a17d459db209bb82f03cbcac4e34 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:04:37 +0000 Subject: [PATCH 1/3] fix(apple-ads): Route Platform API through provider pipeline Platform API calls used the client's default transport, so they had no pacing, 429 backoff, or refresh metrics. The classifier still matched the retired api.searchads.apple.com host, leaving the Apple Ads API policy unreachable. Send requests through the provider HTTP pipeline, classify api.ads.apple.com, retry the read-only query POSTs, and report cancellation as cancellation. Remove the unused AppleSearchAdsJWT and lead the README setup with the Platform API. Co-Authored-By: Claude Opus 5.5 --- OpenASO.xcodeproj/project.pbxproj | 4 - OpenASO/App/AppServices.swift | 4 +- .../AppleAds/AppleAdsPlatformService.swift | 69 ++++++++++++++++ .../Services/AppleAds/AppleSearchAdsJWT.swift | 71 ---------------- OpenASO/Services/MCP/OpenASOMCPRuntime.swift | 2 +- .../Networking/ProviderRequestGate.swift | 16 ++++ .../Networking/RefreshObservability.swift | 2 +- .../AppleAdsPlatformServiceTests.swift | 80 +++++++++++++++++++ OpenASOTests/ProviderRequestGateTests.swift | 46 ++++++++++- OpenASOTests/RefreshObservabilityTests.swift | 2 +- README.md | 2 +- 11 files changed, 217 insertions(+), 81 deletions(-) delete mode 100644 OpenASO/Services/AppleAds/AppleSearchAdsJWT.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index 0462a95..f6f13a5 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -25,7 +25,6 @@ 04E8C4E18F1A4D519C0A1104 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */; }; 04E8C4E18F1A4D519C0A110A /* AppleAds/AppleAdsCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */; }; 04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110D /* SettingsView.swift */; }; - 04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */; }; 04E8C4E18F1A4D519C0A1110 /* KeywordMetricsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */; }; 04E8C4E18F1A4D519C0A1111 /* AppleAds/KeywordMetricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */; }; 04E8C4E18F1A4D519C0A1112 /* SearchRanking/KeywordSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */; }; @@ -392,7 +391,6 @@ 04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconView.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsCredentials.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A110D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleSearchAdsJWT.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordMetricsSource.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/KeywordMetricsService.swift; sourceTree = ""; }; 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordSuggestionService.swift; sourceTree = ""; }; @@ -837,7 +835,6 @@ A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */, FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */, 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */, - 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */, ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift */, 04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */, B9A0000000000000000004 /* AppleAds/AppleAdsPastedSession.swift */, @@ -1322,7 +1319,6 @@ ADF000000000000000000001 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift in Sources */, B9A0000000000000000003 /* AppleAds/AppleAdsPastedSession.swift in Sources */, B9A0000000000000000001 /* AppleAds/AppleAdsWebLoginController.swift in Sources */, - 04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */, A0D71B4C0000000000000009 /* AppDetailSupportTypes.swift in Sources */, A0D71B4C0000000000000019 /* AppDetailPreviewFixtures.swift in Sources */, A0D71B4C000000000000001B /* AppDetailCSVTransfer.swift in Sources */, diff --git a/OpenASO/App/AppServices.swift b/OpenASO/App/AppServices.swift index fe451df..e1f80e6 100644 --- a/OpenASO/App/AppServices.swift +++ b/OpenASO/App/AppServices.swift @@ -124,7 +124,9 @@ final class AppServices { namespace: namespace, loadsEnvironmentCredentials: loadsEnvironmentCredentials ) - let appleAdsPlatformAPI = appleAdsPlatformAPI ?? OfficialAppleAdsPlatformAPI() + let appleAdsPlatformAPI = appleAdsPlatformAPI ?? OfficialAppleAdsPlatformAPI( + httpClient: httpClient + ) let settingsStore = AppSettingsStore(defaults: defaults) let backgroundRefreshAgentController = BackgroundRefreshAgentController( defaults: defaults diff --git a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift index 0436ca4..ff15cd1 100644 --- a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift +++ b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift @@ -1,5 +1,6 @@ import AppleAdsClient import Foundation +import HTTPTypes import OpenAPIRuntime struct AppleAdsPlatformAccount: Codable, Equatable, Identifiable, Sendable { @@ -170,8 +171,72 @@ protocol AppleAdsPlatformAPI: Sendable { ) async throws -> [AppleAdsKeywordSuggestion] } +/// Sends generated client requests through the provider pipeline, so Platform API +/// calls share its pacing, retry, and refresh observation behavior. +struct AppleAdsHTTPClientTransport: ClientTransport { + private static let maximumRequestBodyBytes = 5_000_000 + + let httpClient: any HTTPClient + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + var urlRequest = try await Self.urlRequest(for: request, body: body, baseURL: baseURL) + urlRequest.cachePolicy = .reloadIgnoringLocalCacheData + urlRequest.httpShouldHandleCookies = false + + let (data, response) = try await httpClient.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenASOError.unexpectedResponse + } + return (Self.response(from: httpResponse), HTTPBody(data)) + } + + private static func urlRequest( + for request: HTTPRequest, + body: HTTPBody?, + baseURL: URL + ) async throws -> URLRequest { + guard let url = URL(string: baseURL.absoluteString + (request.path ?? "")) else { + throw OpenASOError.unexpectedResponse + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + for field in request.headerFields { + urlRequest.addValue(field.value, forHTTPHeaderField: field.name.rawName) + } + if let body { + urlRequest.httpBody = try await Data(collecting: body, upTo: maximumRequestBodyBytes) + } + return urlRequest + } + + private static func response(from response: HTTPURLResponse) -> HTTPResponse { + var headerFields = HTTPFields() + for (name, value) in response.allHeaderFields { + guard let name = name as? String, + let value = value as? String, + let fieldName = HTTPField.Name(name) else { continue } + headerFields.append(HTTPField(name: fieldName, value: value)) + } + return HTTPResponse( + status: HTTPResponse.Status(code: response.statusCode), + headerFields: headerFields + ) + } +} + struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { let coverage = AppleAdsPlatformCoverage.current + private let httpClient: any HTTPClient + + init(httpClient: any HTTPClient = URLSessionHTTPClient()) { + self.httpClient = httpClient + } func keywordSuggestions( appStoreID: Int64, @@ -547,11 +612,15 @@ struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { do { return try await AppleAdsClient.withClient( configuration: configuration, + transport: AppleAdsHTTPClientTransport(httpClient: httpClient), body: operation ) } catch let error as OpenASOError { throw error } catch { + if Task.isCancelled { + throw CancellationError() + } throw OpenASOError.providerUnavailable( "Apple Ads Platform API: \(error.localizedDescription)" ) diff --git a/OpenASO/Services/AppleAds/AppleSearchAdsJWT.swift b/OpenASO/Services/AppleAds/AppleSearchAdsJWT.swift deleted file mode 100644 index 5611472..0000000 --- a/OpenASO/Services/AppleAds/AppleSearchAdsJWT.swift +++ /dev/null @@ -1,71 +0,0 @@ -import CryptoKit -import Foundation - -struct AppleSearchAdsJWT { - private struct Header: Encodable { - let alg = "ES256" - let kid: String - } - - private struct Payload: Encodable { - let aud: String - let sub: String - let iss: String - let iat: Int - let exp: Int - } - - let clientID: String - let teamID: String - let keyID: String - let privateKey: String - - func signed() throws -> String { - let issuedAt = Int(Date().timeIntervalSince1970) - let expiresAt = issuedAt + 86_400 - let header = Header(kid: keyID) - let payload = Payload( - aud: "https://appleid.apple.com", - sub: clientID, - iss: teamID, - iat: issuedAt, - exp: expiresAt - ) - let encoder = JSONEncoder() - let signingInput = try [ - encoder.encode(header).base64URLEncodedString(), - encoder.encode(payload).base64URLEncodedString() - ].joined(separator: ".") - - guard let signingData = signingInput.data(using: .utf8) else { - throw OpenASOError.unexpectedResponse - } - - let key = try signingKey(from: privateKey) - let signature = try key.signature(for: signingData) - return "\(signingInput).\(signature.rawRepresentation.base64URLEncodedString())" - } - - private func signingKey(from privateKey: String) throws -> P256.Signing.PrivateKey { - let normalizedKey = privateKey.replacingOccurrences(of: "\\n", with: "\n") - - if normalizedKey.contains("BEGIN") { - return try P256.Signing.PrivateKey(pemRepresentation: normalizedKey) - } - - guard let keyData = Data(base64Encoded: normalizedKey) else { - throw OpenASOError.providerUnavailable("Apple Ads private key must be PEM text or base64 DER.") - } - - return try P256.Signing.PrivateKey(derRepresentation: keyData) - } -} - -private extension Data { - func base64URLEncodedString() -> String { - base64EncodedString() - .replacingOccurrences(of: "=", with: "") - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - } -} diff --git a/OpenASO/Services/MCP/OpenASOMCPRuntime.swift b/OpenASO/Services/MCP/OpenASOMCPRuntime.swift index b85ae78..1a2b04e 100644 --- a/OpenASO/Services/MCP/OpenASOMCPRuntime.swift +++ b/OpenASO/Services/MCP/OpenASOMCPRuntime.swift @@ -102,7 +102,7 @@ enum OpenASOMCPRuntime { namespace: namespace ) } - let appleAdsPlatformAPI = OfficialAppleAdsPlatformAPI() + let appleAdsPlatformAPI = OfficialAppleAdsPlatformAPI(httpClient: httpClient) let mcpService = OpenASOMCPService( backgroundModelStore: backgroundModelStore, keywordResearchProjectStore: keywordResearchProjectStore, diff --git a/OpenASO/Services/Networking/ProviderRequestGate.swift b/OpenASO/Services/Networking/ProviderRequestGate.swift index ca99c5a..48f8ce2 100644 --- a/OpenASO/Services/Networking/ProviderRequestGate.swift +++ b/OpenASO/Services/Networking/ProviderRequestGate.swift @@ -841,6 +841,13 @@ actor ProviderRequestGate: HTTPClient { return .permanent } + /// Read-only Platform API queries that OpenASO sends as POST requests. + private static let appleAdsPlatformQueryPaths = [ + "/v1/campaigns/query", + "/v1/insights/apps/search-term-popularity/query", + "/v1/suggestions/keywords/query", + ] + private static func behavior(for request: URLRequest) -> RequestBehavior { guard request.httpBodyStream == nil else { return .paceOnly @@ -863,6 +870,15 @@ actor ProviderRequestGate: HTTPClient { requiresAdamIDQuery: false ): return .retryOnly + case "POST" where appleAdsPlatformQueryPaths.contains(where: { path in + isHTTPSRequest( + request, + host: "api.ads.apple.com", + path: path, + requiresAdamIDQuery: false + ) + }): + return .retryOnly default: return .paceOnly } diff --git a/OpenASO/Services/Networking/RefreshObservability.swift b/OpenASO/Services/Networking/RefreshObservability.swift index 589a26e..d814de4 100644 --- a/OpenASO/Services/Networking/RefreshObservability.swift +++ b/OpenASO/Services/Networking/RefreshObservability.swift @@ -431,7 +431,7 @@ struct RefreshRequestClassification: Sendable { case "app-ads.apple.com": provider = .appleAdsWeb endpoint = path.contains("/keywords/popularities") ? .appleAdsPopularity : .other - case "api.searchads.apple.com": + case "api.ads.apple.com": provider = .appleAdsAPI endpoint = .appleAdsAPI case "appleid.apple.com": diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 4d5a3b7..d26a6d6 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -1,8 +1,59 @@ import Foundation +import HTTPTypes +import OpenAPIRuntime import Testing @testable import OpenASO struct AppleAdsPlatformServiceTests { + @Test + func transportSendsPlatformRequestsThroughTheHTTPClient() async throws { + let client = RecordingAppleAdsHTTPClient( + statusCode: 200, + headers: ["Content-Type": "application/json", "RateLimit-Remaining": "9"], + body: Data(#"{"result":[]}"#.utf8) + ) + let transport = AppleAdsHTTPClientTransport(httpClient: client) + var request = HTTPRequest(method: .post, scheme: nil, authority: nil, path: "/campaigns/query?limit=1") + request.headerFields[.authorization] = "Bearer token" + request.headerFields[try #require(HTTPField.Name("X-AP-Context"))] = "adAccountId=42" + + let (response, body) = try await transport.send( + request, + body: HTTPBody(#"{"pagination":{}}"#), + baseURL: try #require(URL(string: "https://api.ads.apple.com/v1")), + operationID: "postCampaignsQuery" + ) + + let sent = try #require(client.recordedRequest) + #expect(sent.url?.absoluteString == "https://api.ads.apple.com/v1/campaigns/query?limit=1") + #expect(sent.httpMethod == "POST") + #expect(sent.httpBody == Data(#"{"pagination":{}}"#.utf8)) + #expect(sent.httpBodyStream == nil) + #expect(sent.value(forHTTPHeaderField: "Authorization") == "Bearer token") + #expect(sent.value(forHTTPHeaderField: "X-AP-Context") == "adAccountId=42") + #expect(sent.httpShouldHandleCookies == false) + #expect(response.status == .ok) + #expect(response.headerFields[try #require(HTTPField.Name("RateLimit-Remaining"))] == "9") + let responseBody = try await Data(collecting: try #require(body), upTo: 1_000) + #expect(responseBody == Data(#"{"result":[]}"#.utf8)) + } + + @Test + func transportReturnsErrorStatusesForTheGeneratedClientToHandle() async throws { + let client = RecordingAppleAdsHTTPClient(statusCode: 429, headers: [:], body: Data()) + let transport = AppleAdsHTTPClientTransport(httpClient: client) + + let (response, _) = try await transport.send( + HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/me"), + body: nil, + baseURL: try #require(URL(string: "https://api.ads.apple.com/v1")), + operationID: "getMe" + ) + + #expect(response.status.code == 429) + #expect(client.recordedRequest?.httpBody == nil) + } + @Test func keywordSuggestionRequestKeepsAppMarketAndSeedWithoutRejectedSorting() throws { let request = OfficialAppleAdsPlatformAPI.keywordSuggestionsRequest( @@ -308,3 +359,32 @@ struct AppleAdsPlatformServiceTests { #expect(names.contains("apple_ads_search_term_popularity")) } } + +private final class RecordingAppleAdsHTTPClient: HTTPClient, @unchecked Sendable { + private let lock = NSLock() + private let statusCode: Int + private let headers: [String: String] + private let body: Data + private var storedRequest: URLRequest? + + init(statusCode: Int, headers: [String: String], body: Data) { + self.statusCode = statusCode + self.headers = headers + self.body = body + } + + var recordedRequest: URLRequest? { + lock.withLock { storedRequest } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + lock.withLock { storedRequest = request } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: headers + )! + return (body, response) + } +} diff --git a/OpenASOTests/ProviderRequestGateTests.swift b/OpenASOTests/ProviderRequestGateTests.swift index 1ae72a6..6a9c69f 100644 --- a/OpenASOTests/ProviderRequestGateTests.swift +++ b/OpenASOTests/ProviderRequestGateTests.swift @@ -434,6 +434,50 @@ struct ProviderRequestGateTests { #expect(await retryTransport.requestCount() == 2) } + @Test + func appleAdsPlatformQueryPostsRetryButOtherPlatformPostsDoNot() async throws { + let queries = [ + "https://api.ads.apple.com/v1/campaigns/query", + "https://api.ads.apple.com/v1/insights/apps/search-term-popularity/query", + "https://api.ads.apple.com/v1/suggestions/keywords/query", + ] + for url in queries { + let transport = ScriptedProviderHTTPClient( + steps: [.response(statusCode: 503), .response(statusCode: 200)] + ) + let clock = AdvancingProviderClock() + let gate = makeGate( + base: transport, + policy: makePolicy(maximumAttempts: 2), + clock: clock.clock + ) + let output = try await gate.data(for: request(url, method: "POST")) + + #expect((output.1 as? HTTPURLResponse)?.statusCode == 200) + #expect(await transport.requestCount() == 2) + } + + let writes = [ + "https://api.ads.apple.com/v1/campaigns", + "https://api.ads.apple.com/v1/campaigns/query/delete", + "https://api.ads.apple.com/v1/campaigns/query?unexpected=1", + "http://api.ads.apple.com/v1/campaigns/query", + ] + for url in writes { + let transport = ScriptedProviderHTTPClient( + steps: [.response(statusCode: 503), .response(statusCode: 200)] + ) + let gate = makeGate( + base: transport, + policy: makePolicy(maximumAttempts: 2) + ) + let output = try await gate.data(for: request(url, method: "POST")) + + #expect((output.1 as? HTTPURLResponse)?.statusCode == 503) + #expect(await transport.requestCount() == 1) + } + } + @Test func nearMatchPopularityAndIdentityPostsRemainPaceOnly() async throws { let nearMatches = [ @@ -871,7 +915,7 @@ struct ProviderRequestGateTests { ) let output = try await gate.data( - for: request("https://api.searchads.apple.com/api/v5/campaigns") + for: request("https://api.ads.apple.com/v1/me") ) #expect((output.1 as? HTTPURLResponse)?.statusCode == statusCode) diff --git a/OpenASOTests/RefreshObservabilityTests.swift b/OpenASOTests/RefreshObservabilityTests.swift index e19bb4a..dc877c7 100644 --- a/OpenASOTests/RefreshObservabilityTests.swift +++ b/OpenASOTests/RefreshObservabilityTests.swift @@ -85,7 +85,7 @@ struct RefreshObservabilityTests { ("https://apps.apple.com/us/app/private-app/id1", 302, .appStoreWeb, .ratingsPage, .redirect), ("https://api.appstoreconnect.apple.com/v1/apps/1/customerReviews", 401, .appStoreConnect, .appStoreConnectReviews, .authenticationFailure), ("https://app-ads.apple.com/cm/api/v4/searchads/keywords/popularities", 404, .appleAdsWeb, .appleAdsPopularity, .notFound), - ("https://api.searchads.apple.com/api/v5/campaigns", 429, .appleAdsAPI, .appleAdsAPI, .rateLimited), + ("https://api.ads.apple.com/v1/campaigns/query", 429, .appleAdsAPI, .appleAdsAPI, .rateLimited), ("https://appleid.apple.com/auth/oauth2/token", 500, .appleIdentity, .appleIdentity, .serverFailure), ("https://example.invalid/private/path", 418, .unknown, .other, .clientFailure), ] diff --git a/README.md b/README.md index bdc8e64..3b7586b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The hosted docs are the canonical guide for using OpenASO: 1. Clone the repository. 2. Open `OpenASO.xcodeproj` in Xcode, or build from the command line. 3. Use `.env.example` as a reference if you want to export Apple Ads API credentials as process environment variables. -4. Connect Apple Ads from the app Settings window. Sign-in opens in a WebKit window inside OpenASO, so 2FA and passkeys behave like they do in Safari. If you would rather sign in with the browser you already use, expand "Sign In With My Own Browser" and paste the cookie header from an Apple Ads page. +4. Connect Apple Ads from the app Settings window. Enter Apple Ads Platform API credentials for Apple's supported keyword popularity data. Web Access is optional: it fills in terms the API does not return, or supplies all popularity when the API is not connected. Its sign-in opens in a WebKit window inside OpenASO, so 2FA and passkeys behave like they do in Safari. If you would rather sign in with the browser you already use, expand "Sign In With My Own Browser" and paste the cookie header from an Apple Ads page. 5. Enter App Store Connect credentials in the app Settings window when needed. Anonymous analytics are enabled by default and can be disabled at any time in Settings. OpenASO uses anonymous product usage events to understand which features are used and how often; it does not collect identifying information or details about your apps, keywords, reviews, replies, credentials, search text, or countries. From c83b7db10ef73432188dabac5357b8b49805b5f7 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:13:02 +0000 Subject: [PATCH 2/3] docs: Remove research directory Co-Authored-By: Claude Opus 5.5 --- research/asc-cli-opportunities-2026-10-26.md | 60 -------------------- research/fork-opportunities-2026-10-26.md | 36 ------------ 2 files changed, 96 deletions(-) delete mode 100644 research/asc-cli-opportunities-2026-10-26.md delete mode 100644 research/fork-opportunities-2026-10-26.md diff --git a/research/asc-cli-opportunities-2026-10-26.md b/research/asc-cli-opportunities-2026-10-26.md deleted file mode 100644 index cc05a7a..0000000 --- a/research/asc-cli-opportunities-2026-10-26.md +++ /dev/null @@ -1,60 +0,0 @@ -# ASC CLI opportunities for OpenASO - -Checked 26 October 2026 against primary upstream source and Apple documentation. This is a research note; no upstream implementation is copied and no application credentials or store metadata are changed. - -## Version and scope - -The installed `asc version` reports **5.14.0**. GitHub's latest-release API reports **5.17.0**, published **25 October 2026 at 22:49:09 UTC**, with source commit `ecad9f122524248749b6c44bb01e951734131889`. The intervening releases mainly improve authentication recovery, diagnostics, security, and resource reads. The directly relevant new 5.17 feature is custom product page keyword validation. Broader keyword discovery and optimization features below also exist in the current CLI, but should not all be described as newly introduced since 5.14. [5.17.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.17.0), [5.16.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.16.0), [5.15.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.15.0). - -OpenASO's current branch already implements saved-credential keyword suggestions, seeded/paged expansion, independent market popularity, authenticated metadata snapshots, relevance and coverage audits, draft comparison, and immutable local release baselines. Acquisition counts in those baselines are caller-provided. The retained competition estimator and persistence types are a foundation that still needs production wiring; their existence does not mean current MCP audits produce fresh competition evidence. See `README.md`, `OpenASO/Services/MCP/KeywordTargetingAnalysis.swift`, and `OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift`. - -## Recommended additions before release - -### 1. Expand discovery with official phrase suggestions - -ASC combines app keyword suggestions with **phrase suggestions**, whereas OpenASO currently calls only the keyword endpoint. Apple offers a second read-only discovery surface at `POST /v1/suggestions/phrases/query`, including app discovery and partial-text searches. This is a direct fit for finding more relevant, longer queries. [ASC discovery design](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/optimize-keywords.md), [Apple suggestion endpoints](https://developer.apple.com/documentation/apple-ads-platform-api/suggestions-endpoints). - -Implementation can reuse the pinned **Apple Ads Swift SDK v1.109.0** and saved Apple Ads account credentials. That version already exposes `queryPhraseSuggestions(headers:body:)`; a dependency upgrade is unnecessary. Discovery needs filters `queryType=SUGGESTION`, `promotedObjectId=`, and `promotedObjectType=APPSTORE_APP`. Branching can use `queryType=SEARCH` with `phrase LIKE`; exact lookup uses `phrase IN`. The search route does not need an app ID. No campaign is required. [Pinned SDK generated client](https://github.com/apple/apple-ads-platform-api-swift/blob/v1.109.0/Sources/AppleAdsClient/Generated/Client.swift), [Apple phrase query contract](https://developer.apple.com/documentation/apple-ads-platform-api/query-phrase-suggestions). - -The result has optional `phrase` and `popularity`. Neither the phrase result nor its documented filters includes a country dimension. Keep that contextual score separate from the existing country-specific popularity lookup, and pass newly discovered phrases through that lookup before recommending additions. Do not describe phrase scores as local search volume. The SDK exposes request `offset`/`pageSize` and response `offset`/`pageSize`/`totalCount`; retain independent source pagination and deduplicate across both endpoints. [SDK response and request types](https://github.com/apple/apple-ads-platform-api-swift/blob/v1.109.0/Sources/AppleAdsClient/Generated/Types.swift), [ASC source scope and bounded discovery](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/optimize/keywords_discover.go). - -Release-sized scope: one provider method, source-aware DTOs/pagination, MCP expansion and skill guidance. Bound phrase discovery independently, fetch app-wide phrases once per app/page rather than repeating them for each storefront, preserve successful keyword results if phrases fail, and omit popularity sorting because live APIs have rejected that sort despite examples suggesting it. No persistent schema change is required. [ASC observed request handling](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/ads/search_optimization.go). - -Live qualification: read-only probes for both monitored apps returned working keyword suggestions, while each app-associated phrase source returned HTTP 400 indicating that the promoted app was missing or inaccessible to the ad account. The documented app-independent `SEARCH` route then accepted four `phrase LIKE` queries (`dictation`, `voice`, `focus`, and the documented example `productivity`) but returned zero phrases in every case. The unadorned LIKE syntax matches Apple's documented example. Existing Ads credentials therefore do not guarantee useful phrase results. Preserve partial source failures and zero-result evidence; treat phrase support as experimental until it demonstrates incremental candidates for this account. It should not delay this release on an unsupported claim of better coverage. Raw probes remain outside the repository. Installed 5.14 supports `asc ads suggestions phrases find --file --ads-profile ` for these checks. - -### 2. Connect competition evidence to the shortlist and targeting audit - -The CLI evaluates candidate reachability from competitor evidence as well as demand. OpenASO already retains its own top-ten rating-authority and metadata-saturation estimator with confidence and evidence types. Wire that foundation into bounded native ranking samples, then show difficulty, confidence, sample count, source, time, and relevant competitor examples alongside popularity and metadata gaps. This helps distinguish a popular term from a practical target. This is an application analysis heuristic, not an Apple-provided difficulty metric. [ASC evaluation methodology and limitations](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/optimize-keywords.md). - -Reuse OpenASO's estimator instead of importing ASC's alternative formula. Treat absent ratings, too few results, stale samples, and iTunes fallback as explicit limitations; they must not become an easy-keyword score. Read-only audits should calculate ephemeral evidence without silently storing refresh data. Persist evidence only through an explicit refresh path. Release scope is moderate because production integration is missing even though model types exist. Avoid a database migration merely to expose a read-only audit result. - -### 3. Protect custom product page keyword assignments - -ASC 5.17 adds an advisory check for assigned custom page keywords absent from the selected app version's same-locale keyword field. Apple allows custom pages to appear for assigned keywords from the latest approved version, so a draft that removes an assigned term needs a visible warning. This extends OpenASO's current draft protection beyond title/subtitle demotion and existing native winners. [5.17 feature and correction](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.17.0), [Apple custom page configuration](https://developer.apple.com/help/app-store-connect/create-custom-product-pages/configure-multiple-product-page-versions). - -Use the existing App Store Connect JWT provider for a read-only resource chain: - -1. `GET /v1/apps/{app}/appCustomProductPages`. -2. `GET /v1/appCustomProductPages/{page}/appCustomProductPageVersions`. -3. `GET /v1/appCustomProductPageVersions/{version}/appCustomProductPageLocalizations`. -4. Read included search keyword relationship IDs, checking completeness; alternatively list `GET /v1/appCustomProductPageLocalizations/{localization}/searchKeywords` with pagination. - -Apple documents the [app/page resources](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-pages), [page versions](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-page-versions), [localizations](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-page-localizations), and [keyword read](https://developer.apple.com/documentation/appstoreconnectapi/get-v1-appcustomproductpagelocalizations-_id_-searchkeywords). - -The verified ASC behavior is **iOS/iPadOS only**, excluding replaced page versions. It compares each keyword as a normalized whole phrase against comma-separated keyword entries in the same locale; title/subtitle token combinations are not a substitute. Read failures or incomplete relationships produce **unverified**, rather than a clean pass. ASC uses `limit[searchKeywords]=50` on included relationships and validates reported totals. Implement from Apple's contract rather than copying ASC code. Bound page/version/localization reads and preserve partial evidence. No page assignment, publishing, or metadata write is needed. [ASC read integration](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/validate/custom_product_pages.go), [iOS and failure guards](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/validate/readiness.go), [phrase membership check](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/validation/custom_product_pages.go). - -## Other worthwhile improvements - -| Addition | Value and scope | Authentication and limits | -| --- | --- | --- | -| Localization keyword hygiene | Add malformed separators, empty segments, configurable blocked terms, and unused-budget findings to current draft validation; add a bounded multi-locale inventory later. Repeated terms across locales are review evidence, not automatic proof of wasted indexing. | Same ASC credentials for owned fields; pure validation itself needs none. Most duplicate/filler/length checks already exist in OpenASO, so extend rather than duplicate. [ASC audit contract](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/metadata/keywords_audit.go). | -| App tag audit | Read Apple's assigned tags and visibility, explain whether tags match supported app features, and use them as relevance context. Developers can deselect assigned tags; this is not an API for inventing tags or buying rankings. | Owned-app ASC credentials. Start with read-only tag names and visibility. Territory relations are deprecated in current API coverage; do not build new targeting behavior around them. [Apple tags API](https://developer.apple.com/documentation/appstoreconnectapi/app-tags), [ASC tag support/deprecation](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/apps/app_tags_deprecation.go). | -| Automatic acquisition measurements | Replace optional manually supplied baseline counts with parsed engagement and download report data, report completeness, equal comparison windows, and acquisition-source breakdowns. Valuable, but a larger follow-up than the three bounded features above. | ASC report access and an existing/generated report request; creation is a separate remote operation. Requires downloading and parsing report segments, handling revised instances, suppression, and compatible dimensions. App Store Search includes Apple Ads, so source totals are not purely organic or per-keyword attribution. [Apple engagement report](https://developer.apple.com/documentation/analytics-reports/app-store-discovery-and-engagement), [Apple download report](https://developer.apple.com/documentation/analytics-reports/app-download). | - -## Measurement boundaries that affect recommendations - -`asc analytics compare` currently accepts **Sales** as its comparison source. `asc insights weekly --source analytics` summarizes report/instance availability and explicitly leaves business conversion unavailable; it does not parse acquisition metrics from report payloads. Copying those commands' surface alone would not deliver automatic ASO impact measurement. [Analytics comparison source](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/analytics/analytics_compare.go), [insights implementation](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/insights/insights.go). - -Paid search-term reports can identify queries with actual taps and installs, which is stronger paid-conversion evidence than popularity alone. ASC's optimization plan joins these reports with suggestions, targeting, and impression share. This is useful only when the account has relevant campaign history; it neither establishes organic conversion nor guarantees that a metadata edit improves ranking. Keep it a separate later tool with paid provenance. [ASC cross-API plan](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/official-search-optimization-plan.md), [Apple search-term reporting guidance](https://developer.apple.com/documentation/apple-ads-platform-api/journey-app-store-ads). - -No verified recent ASC release introduces a new country-specific popularity source that eliminates low/floor values. Phrase discovery broadens candidates; relevance, independent market-popularity verification, native ranking evidence, and competition assessment remain necessary. diff --git a/research/fork-opportunities-2026-10-26.md b/research/fork-opportunities-2026-10-26.md deleted file mode 100644 index 7a7e7a5..0000000 --- a/research/fork-opportunities-2026-10-26.md +++ /dev/null @@ -1,36 +0,0 @@ -# OpenASO fork review before 0.4.6 - -Reviewed 26 October 2026 against baseline `d21d334`. This records feature research, not an import of fork code. The gaps below describe that baseline. The approved follow-up implements competition evidence in audits, scoped MCP untracking, and a Stop control; tags, market comparisons, and other proposals remain future work. - -## Coverage and method - -GitHub lists **29 forks**. Their published branch lists contain **91 branches**; all were compared with upstream `main` after merging PRs #87 and #88. Eight forks contain commits outside that upstream history. Every fork reports zero child forks. The remaining 21 forks have no unique commits in the inspected branches. These counts describe published branches, not private or deleted work. [Fork inventory API](https://api.github.com/repos/hubab1/OpenASO/forks?per_page=100), [upstream fork network](https://github.com/hubab1/OpenASO/forks). - -Ahead/behind counts alone overstate useful additions: several forks retain old versions or differently integrated commits for capabilities already present in OpenASO. Review current behavior and individual changes rather than merging a fork wholesale. Raw branch/commit/file inventories are saved locally under `/tmp/openaso-fork-research-20261026`; they are not part of this note. - -## Features worth considering - -| Feature | Verified fork evidence | Current gap and recommendation | -| --- | --- | --- | -| Keyword tags and filtering | Heondo adds a tag editor, table chips, filters, CSV round trips, and MCP tag updates. [Feature commit](https://github.com/heondo/OpenASO/commit/c10829c8b34124f4d06d6cbb89afb8c737e1195a). | Useful for separating brand, core intent, experiments, rejected terms, and metadata targets. Current exports leave the tag column empty and MCP has no tag tool. Requires persistent model/UI integration and migration validation; a larger follow-up than a tool-only improvement. | -| Safe MCP untracking | Heondo adds `remove_keywords`, scoped by app/storefront/platform, with atomic batch handling and idempotent missing-track behavior. [Removal commit](https://github.com/heondo/OpenASO/commit/bf1e9875db7c7da1f45a667295b18dc3997c8cf4). | Agents can currently add but cannot retire tracked terms. A useful bounded addition: preserve shared query observations and popularity used by other apps, validate exact scope, and report removed/skipped terms. Adapt deletion to current normalized V6 ranking and V7 rating storage rather than copying older relationship cleanup. | -| Stop a manual refresh | Heondo retains the refresh task, offers Stop/Stopping states, and prevents cancelled work from creating false failures or immediately launching another refresh. [Stop commit](https://github.com/heondo/OpenASO/commit/acd69fd25b43257cd8341d7ce9ecd2d8203537df). | Current services have cancellation checks, but the manual refresh UI does not retain a user-cancellable task. Valuable before release if tested across single-app and multi-app runs. Stop must affect the operation the view owns and preserve committed results. | -| Competition evidence and country comparison | Nicremo has dormant branches for bounded country comparisons, a market detail UI, and `get_estimated_keyword_difficulty`. [MCP addition](https://github.com/nicremo/OpenASO/commit/fcc85a79b51c7ecc6fdfe2fddfc375ee514a8ef8), [market UI branch](https://github.com/nicremo/OpenASO/tree/23b-market-insights-ui). Heondo also wires a top-ten heuristic into refreshes. | OpenASO retains a provenance-aware estimator/store but has no production callers or MCP result exposure. First connect ephemeral native competition evidence to the targeting audit. Then build a bounded country matrix with missing/stale/fallback states. Missing results must not imply low competition, and popularity indices across countries are not additive market sizes. | -| Shared keyword sets | Akshaynexus adds a sidebar manager, applying shared terms to apps, and automatic all-market synchronization. [Global manager](https://github.com/akshaynexus/OpenASO/commit/9da616b88cc0fe2d48d846965289f31f521f5c51), [sync service source](https://github.com/akshaynexus/OpenASO/blob/main/OpenASO/Services/AppDetail/GlobalKeywordSync.swift). | Reusable sets are useful, but OpenASO already has research projects and explicit copying to tracked apps. Extend that workflow with named reusable sets and deliberate market selection. Automatic propagation to every app/market can pollute relevance and expand refresh work; do not adopt that behavior by default. | -| Headless and scheduled-run reliability | Heondo adds schedule read/write tools, persisted diagnostics, a longer run budget, and a process entry point that routes MCP/background work before SwiftUI/AppKit initializes. [Refresh hardening](https://github.com/heondo/OpenASO/commit/3009d84f7cc330a0102f480b943b054990e63b0c), [schedule tools](https://github.com/heondo/OpenASO/commit/4fe80266e99d175feb26cf62589cf3eea9a044fb), [headless entry fix](https://github.com/heondo/OpenASO/commit/6538c0be963a08f705dfa9c9531f96cd2e4894c0). | Current startup still enters SwiftUI and touches `NSApplication.shared` for headless modes. The fork reports that this can cause normal app launches to activate a windowless process. Reproduce in an isolated namespace before claiming the fix is necessary. Audit noninteractive Keychain behavior and background deadlines separately; do not import its broad startup/background rewrite without testing current storage preparation. | -| Apple TV keyword rankings | Wojciecholszewski's `tvos-support` branch adds a platform, storefront routing, and ranking tests. [tvOS commit](https://github.com/wojciecholszewski/OpenASO/commit/b7374a8361bc6c671547a133020bf8ee371c3c66). | Concrete new platform, but lower value for the currently researched iPhone apps. Requires checking persisted platform values, native/fallback routing, metadata platform mapping, and discovery support. Defer from a focused ASO release. | - -## Useful work already represented upstream - -- FelixLisczyk's stdio Apple Ads dependencies, Keychain failures, stale popularity status cleanup, MCP path validation, and refresh selection fairness have corresponding upstream implementations. Current HTTP routing already rejects paths other than `/mcp`. [Fork commits](https://github.com/FelixLisczyk/OpenASO/commits/main), [current runtime](../OpenASO/Services/MCP/OpenASOMCPRuntime.swift), [current controller](../OpenASO/Services/MCP/OpenASOMCPServerController.swift). -- Roeland's main branch mainly adjusts minimum sizes and screen fitting. Current root window placement and sheets already include related work. Treat individual remaining sizing differences as targeted UI checks. [Fork commits](https://github.com/roeland/OpenASO/commits/main), [current app startup](../OpenASO/App/OpenASOApp.swift). -- Many Nicremo branches reproduce storage migration, provider gating, ranking provenance, research projects, pricing, and headless foundations already represented in upstream. The market-insights and difficulty exposure branches are the useful remaining leads, rather than the complete older stack. [Fork branches](https://github.com/nicremo/OpenASO/branches), [current source](../OpenASO). -- Spencershwetz's unique commit consists of ranking-refresh artifacts; Sbenodiz's unique `pr-1-assets` branch adds a screenshot. Neither supplies a new application feature. [Artifact commit](https://github.com/spencershwetz/OpenASO/commit/470f22480e59679fb70fd21cc3d166c30e1bbcb3), [screenshot branch](https://github.com/sbenodiz/OpenASO/tree/pr-1-assets). - -## Suggested scope - -For a focused pre-release increment, prioritize **competition evidence in keyword audits**, **safe MCP untracking**, and **a Stop control for manual refreshes**. Tags and the cross-country matrix are valuable next steps but involve more persistent/UI integration. Investigate the headless launch issue as a separate release-quality check. - -For ASC-specific opportunities and live API qualifications, see [ASC CLI research](asc-cli-opportunities-2026-10-26.md). Phrase discovery is an additional candidate source, not a replacement popularity API or evidence that floor scores disappear. - -No fork code has been copied or merged during this review. Any future port should retain the relevant license notices and credit the original contributor, and must be adapted to current storage and concurrency boundaries. From 3d1b4065eeb38bd32455be1c4e0866fdbab018e6 Mon Sep 17 00:00:00 2001 From: hubab1 <50897577+hubab1@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:21:09 +0000 Subject: [PATCH 3/3] fix(mcp): Use injected clock for competitor lookback The competitor lookback read the system clock instead of the service's injected now. Test fixtures dated May 2026 fell outside the 180-day window around October 28, which broke four tests on main. Co-Authored-By: Claude Opus 5.5 --- OpenASO/Services/MCP/OpenASOMCPService.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 6d2779b..e52062b 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -912,6 +912,7 @@ final class OpenASOMCPService: Sendable { func getAppOverview(appStoreID: Int64) async throws -> OpenASOMCPAppOverview { let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let asOf = now() return try await backgroundModelStore.read { modelContext in guard let storeApp = try Self.fetchStoreApp(appStoreID: appStoreID, in: modelContext) else { throw OpenASOError.appNotFound @@ -943,6 +944,7 @@ final class OpenASOMCPService: Sendable { storefronts: [], platform: nil, lookbackDays: 180, + asOf: asOf, limit: ResponseLimits.overviewCompetitors, evidenceLimit: ResponseLimits.overviewCompetitorEvidence, in: modelContext @@ -2314,12 +2316,14 @@ final class OpenASOMCPService: Sendable { default: ResponseLimits.defaultCompetitorEvidence, maximum: ResponseLimits.maximumCompetitorEvidence ) + let asOf = now() return try await backgroundModelStore.read { modelContext in try Self.deriveCompetitors( appStoreID: appStoreID, storefronts: storefronts, platform: platform, lookbackDays: lookbackDays, + asOf: asOf, limit: limit, evidenceLimit: evidenceLimit, in: modelContext @@ -5473,6 +5477,7 @@ extension OpenASOMCPService { storefronts: [String], platform: AppPlatform?, lookbackDays: Int, + asOf: Date, limit: Int, evidenceLimit: Int, in modelContext: ModelContext @@ -5481,7 +5486,7 @@ extension OpenASOMCPService { let queryKeys = trackedApp.map { Set($0.keywordTracks.map(\.queryKey)) } ?? [] guard !queryKeys.isEmpty else { return [] } - let cutoff = Date().addingTimeInterval(-Double(max(1, lookbackDays)) * 86_400) + let cutoff = asOf.addingTimeInterval(-Double(max(1, lookbackDays)) * 86_400) let storefrontSet = Set(storefronts) let descriptor = FetchDescriptor( predicate: #Predicate { ranking in