diff --git a/CHANGELOG.md b/CHANGELOG.md index 512d6946db..8df5cee23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## Unreleased + +### Fixes + +- Fixes the debugger paywall preview so its resolve request authenticates with the debugger's signed preview token instead of the app's public API key. + ## 4.16.2 ### Enhancements diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 6c1bf36d07..c39e7ab207 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -216,6 +216,24 @@ public final class SuperwallOptions: NSObject, Encodable { } } + /// Host for the Superwall V2 API (the `apps/api` Cloudflare Worker), whose + /// routes live under a `/v2/` path. + /// + /// This is a DIFFERENT host from ``baseHost`` (the legacy v1 API on + /// `api.superwall.me`): the V2 API is served from the `superwall.com` + /// domain — `api.superwall.com` in production and `api.superwall.dev` in the + /// developer/staging environment. + var apiV2Host: String { + switch self { + case .developer: + return "api.superwall.dev" + case .local: + return "localhost:3001" + default: + return "api.superwall.com" + } + } + /// The base URL for the Superwall dashboard. var dashboardBaseUrl: String { switch self { diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 37a7e31a62..d371b1bd4a 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -118,7 +118,11 @@ final class DebugViewController: UIViewController { var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? - var paywalls: [Paywall] = [] + /// Backs the "Your Paywalls" picker. Populated from + /// `GET /v2/paywalls/preview-list` (id/name/slug only — not the full paywalls + /// the pre-#3456 fetch-all returned). Empty when the request fails or the app + /// has a single paywall, in which case the picker declines to open. + var previewPaywalls: [PaywallPreviewListItem] = [] var previewViewContent: UIView? private var cancellable: AnyCancellable? private var initialLocaleIdentifier: String? @@ -156,6 +160,12 @@ final class DebugViewController: UIViewController { initialLocaleIdentifier = Superwall.shared.options.localeIdentifier addSubviews() Task { await loadPreview() } + // Independent of the preview load on purpose. The picker is most useful + // precisely when the requested paywall fails to render — that is when you + // want to switch to another one — so it must not sit behind the preview's + // success path. Runs concurrently, and only here, so switching paywalls via + // the picker doesn't refetch a list that cannot have changed. + Task { await loadPreviewPaywalls() } } private func addSubviews() { @@ -204,32 +214,31 @@ final class DebugViewController: UIViewController { func loadPreview() async { activityIndicator.startAnimating() previewViewContent?.removeFromSuperview() + await finishLoadingPreview() + } + + func finishLoadingPreview() async { + var paywallId: String? - if paywalls.isEmpty { + if let paywallIdentifier = paywallIdentifier { + paywallId = paywallIdentifier + } else if let paywallDatabaseId = paywallDatabaseId { + // Resolve the numeric database id from the deep link to the paywall's + // identifier (slug) with a single lookup, rather than fetching every + // paywall for the app and filtering in memory. do { - paywalls = try await network.getPaywalls() - await finishLoadingPreview() + let resolution = try await network.resolvePaywallIdentifier(forDatabaseId: paywallDatabaseId) + paywallId = resolution.identifier + paywallIdentifier = resolution.identifier } catch { Logger.debug( logLevel: .error, scope: .debugViewController, - message: "Failed to Fetch Paywalls", + message: "Failed to Resolve Paywall", error: error ) + return } - } else { - await finishLoadingPreview() - } - } - - func finishLoadingPreview() async { - var paywallId: String? - - if let paywallIdentifier = paywallIdentifier { - paywallId = paywallIdentifier - } else if let paywallDatabaseId = paywallDatabaseId { - paywallId = paywalls.first { $0.databaseId == paywallDatabaseId }?.identifier - paywallIdentifier = paywallId } else { return } @@ -310,20 +319,49 @@ final class DebugViewController: UIViewController { } } - @objc func pressedPreview() { - guard let id = paywallDatabaseId else { return } + /// Populates the "Your Paywalls" picker from the application in the debugger's + /// preview token. + /// + /// Kicked off from `viewDidLoad` alongside — not after — the preview load, so + /// the picker is available even when the requested paywall fails to render. + /// Best-effort: a failure leaves `previewPaywalls` empty and `pressedPreview` + /// declines to open, which is the behaviour before this was restored. + private func loadPreviewPaywalls() async { + do { + let list = try await network.listPreviewPaywalls() + previewPaywalls = list.data + } catch { + Logger.debug( + logLevel: .warn, + scope: .debugViewController, + message: "Failed to Load Paywall Picker", + info: nil, + error: error + ) + } + } - let options: [AlertOption] = paywalls.map { paywall in + @objc func pressedPreview() { + // Open whenever there is something to switch *to*. That covers an empty list + // (the request failed) and a single-entry list whose one paywall is already + // on screen, without gating on `paywallDatabaseId` — which is nil when the + // deep link carried no `paywall_id` and nothing rendered. That is precisely + // when the picker is most useful, so it must not be inert then. + guard previewPaywalls.contains(where: { $0.id != paywallDatabaseId }) else { return } + + let options: [AlertOption] = previewPaywalls.map { paywall in var name = paywall.name - if id == paywall.databaseId { + // Optional comparison: with no paywall on screen nothing is marked, which + // is correct rather than a case to guard against. + if paywall.id == paywallDatabaseId { name = "\(name) ✓" } let alert = AlertOption( title: name, action: { [weak self] in - self?.paywallDatabaseId = paywall.databaseId + self?.paywallDatabaseId = paywall.id self?.paywallIdentifier = paywall.identifier Task { await self?.loadPreview() } }, diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 4b943d5221..cb7a0fda19 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -8,8 +8,59 @@ import UIKit -struct Paywalls: Decodable { - var paywalls: [Paywall] +/// The minimal paywall metadata returned by the V2 resolver endpoint +/// (`GET /v2/paywalls/resolve`). +/// +/// The debug/preview deep link carries a numeric paywall database `id`, but the +/// full-paywall fetch is keyed by `identifier` (slug). This lets the preview +/// flow translate `id` → `identifier` with a single lookup instead of fetching +/// every paywall for the app. +/// +/// Decoded with `JSONDecoder.fromSnakeCase`; the endpoint also returns +/// `application_id`, which is not needed here and is ignored. +struct PaywallIdentifierResolution: Decodable { + /// The id of the paywall in the database. + let id: String + + /// The identifier (slug) of the paywall, used to fetch the full paywall. + let identifier: String + + /// The display name of the paywall. + let name: String +} + +/// One row of the debugger's paywall picker, from +/// `GET /v2/paywalls/preview-list`. +/// +/// Structurally identical to ``PaywallIdentifierResolution`` — the picker needs +/// exactly what the resolver returns, for every paywall in the application +/// rather than one. Kept as its own type so the two endpoints can diverge. +struct PaywallPreviewListItem: Decodable { + /// The id of the paywall in the database. + let id: String + + /// The identifier (slug) of the paywall, used to fetch the full paywall. + let identifier: String + + /// The display name of the paywall. + let name: String +} + +/// The response from `GET /v2/paywalls/preview-list`. +/// +/// Lists the non-archived paywalls of the application in the debugger's `sat_` +/// preview token, so the picker can offer alternatives without fetching every +/// paywall in full. Deliberately carries no presentable paywall JSON. +/// +/// Decoded with `JSONDecoder.fromSnakeCase`. The endpoint also returns `object`, +/// `has_more` and `application_id`; none are decoded here. `has_more` in +/// particular is deliberately omitted rather than declared and ignored — the +/// picker does not paginate, and a non-optional field nothing reads would turn +/// any future change in the response shape into a `keyNotFound` that empties the +/// whole picker. +struct PaywallPreviewList: Decodable { + /// The paywalls available to preview, capped server-side. + let data: [PaywallPreviewListItem] } struct Paywall: Codable { diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index d84a8cfb99..d8a45fd318 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -13,6 +13,7 @@ enum EndpointHost { case enrichment case adServices case subscriptionsApi + case paywallsV2 case mmp } @@ -35,6 +36,7 @@ struct Api { let enrichment: Enrichment let adServices: AdServices let subscriptionsApi: SubscriptionsAPI + let paywallsV2: PaywallsV2 let mmp: MMP init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { @@ -43,6 +45,7 @@ struct Api { enrichment = Enrichment(networkEnvironment: networkEnvironment) adServices = AdServices(networkEnvironment: networkEnvironment) subscriptionsApi = SubscriptionsAPI(networkEnvironment: networkEnvironment) + paywallsV2 = PaywallsV2(networkEnvironment: networkEnvironment) mmp = MMP(networkEnvironment: networkEnvironment) } @@ -58,6 +61,8 @@ struct Api { return adServices case .subscriptionsApi: return subscriptionsApi + case .paywallsV2: + return paywallsV2 case .mmp: return mmp } @@ -115,6 +120,19 @@ struct Api { } } + /// The Superwall V2 API, served under a `/v2/` path on `api.superwall.com` + /// (production) / `api.superwall.dev` (developer). See + /// `NetworkEnvironment.apiV2Host`. + struct PaywallsV2: ApiHostConfig { + let networkEnvironment: SuperwallOptions.NetworkEnvironment + var host: String { return networkEnvironment.apiV2Host } + var path: String { return "/v2/" } + + init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { + self.networkEnvironment = networkEnvironment + } + } + struct MMP: ApiHostConfig { let networkEnvironment: SuperwallOptions.NetworkEnvironment var host: String { return networkEnvironment.mmpHost } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index bf4c600c93..304f686051 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -207,15 +207,53 @@ extension Endpoint where } } -// MARK: - PaywallsResponse +// MARK: - PaywallIdentifierResolution extension Endpoint where Kind == EndpointKinds.Superwall, - Response == Paywalls { - static func paywalls() -> Self { + Response == PaywallIdentifierResolution { + /// Resolves a numeric paywall database id to its identifier (slug) via the V2 + /// resolver endpoint (`GET /v2/paywalls/resolve?id=`). + /// + /// Used by the debug/preview flow so it no longer has to fetch every paywall + /// for the app just to translate a deep-link `paywall_id` into an identifier. + /// Authenticated with the debugger's signed preview token (the `sat_` token + /// from the deeplink, sent as `Authorization: Bearer `), not the + /// app's public key (see `Network.resolvePaywallIdentifier`). + static func resolvePaywall( + byDatabaseId databaseId: String, + retryCount: Int + ) -> Self { return Endpoint( + retryCount: retryCount, components: Components( - host: .base, - path: "paywalls" + host: .paywallsV2, + path: "paywalls/resolve", + queryItems: [URLQueryItem(name: "id", value: databaseId)] + ), + method: .get + ) + } +} + +// MARK: - PaywallPreviewList +extension Endpoint where + Kind == EndpointKinds.Superwall, + Response == PaywallPreviewList { + /// Lists the paywalls available to preview for the application in the + /// debugger's signed preview token (`GET /v2/paywalls/preview-list`). + /// + /// Backs the debugger's "Your Paywalls" picker. Returns id/identifier/name + /// only — never the presentable paywall JSON — so switching previews costs one + /// small request rather than the fetch-all this replaced. + /// + /// Same auth as `resolvePaywall`: the `sat_` token from the deeplink, sent as + /// `Authorization: Bearer ` (see `Network.listPreviewPaywalls`). + static func listPreviewPaywalls(retryCount: Int) -> Self { + return Endpoint( + retryCount: retryCount, + components: Components( + host: .paywallsV2, + path: "paywalls/preview-list" ), method: .get ) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index cc11ddd8f4..38f6b9488a 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -122,21 +122,63 @@ class Network { } } - func getPaywalls() async throws -> [Paywall] { + /// Resolves a numeric paywall database id to its identifier (slug) so the + /// debug/preview flow can fetch a single paywall instead of all of them. + /// + /// Authenticated with the debugger's signed preview token (the `sat_` token + /// from the debugger deeplink, stored in `storage.debugKey`) via + /// `isForDebugging: true`, which `makeHeaders` sends as + /// `Authorization: Bearer ` — not the app's public key. + func resolvePaywallIdentifier( + forDatabaseId databaseId: String, + retryCount: Int = 6 + ) async throws -> PaywallIdentifierResolution { do { - let response = try await urlSession.request( - .paywalls(), + return try await urlSession.request( + .resolvePaywall( + byDatabaseId: databaseId, + retryCount: retryCount + ), + data: SuperwallRequestData( + factory: factory, + isForDebugging: true + ) + ) + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /v2/paywalls/resolve", + error: error + ) + throw error + } + } + + /// Lists the paywalls available to preview for the application in the + /// debugger's signed preview token, backing the debugger's paywall picker. + /// + /// Same auth as ``resolvePaywallIdentifier(forDatabaseId:retryCount:)``: the + /// `sat_` token from the debugger deeplink (stored in `storage.debugKey`) via + /// `isForDebugging: true`, not the app's public key. + /// + /// Retries less than the resolver: this only populates an optional picker, so + /// a failure should degrade to "no alternatives to offer" quickly rather than + /// hold the debugger up. + func listPreviewPaywalls(retryCount: Int = 2) async throws -> PaywallPreviewList { + do { + return try await urlSession.request( + .listPreviewPaywalls(retryCount: retryCount), data: SuperwallRequestData( factory: factory, isForDebugging: true ) ) - return response.paywalls } catch { Logger.debug( logLevel: .error, scope: .network, - message: "Request Failed: /paywalls", + message: "Request Failed: /v2/paywalls/preview-list", error: error ) throw error diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index ac9f1fd86f..bf90bab689 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -179,4 +179,108 @@ struct NetworkTests { #expect(bodyJson["deviceId"] as? String == "device_123") #expect(bodyJson["appUserId"] as? String == "user_123") } + + @Test func resolvePaywall_endpointBuildsRequest() async throws { + let dependencyContainer = DependencyContainer() + dependencyContainer.storage.debugKey = "sat_test" + let endpoint = Endpoint.resolvePaywall( + byDatabaseId: "123", + retryCount: 6 + ) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true), + factory: dependencyContainer + ) + + #expect(urlRequest?.httpMethod == "GET") + + let urlString = try #require(urlRequest?.url?.absoluteString) + // Host and path asserted together: matching the path alone passes whichever + // host the endpoint resolved to, which is how the V2 resolver shipped + // pointing at the v1 `baseHost` (fixed in b073dda). + #expect(urlString.contains("api.superwall.com/v2/paywalls/resolve")) + #expect(urlString.contains("id=123")) + + // The resolver authenticates with the debugger's signed preview token + // (isForDebugging: true), so the Authorization header carries the + // `sat_` debug key as a bearer token, not the app's public key. + #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer sat_test") + } + + @Test func listPreviewPaywalls_endpointBuildsRequest() async throws { + let dependencyContainer = DependencyContainer() + dependencyContainer.storage.debugKey = "sat_test" + let endpoint = Endpoint.listPreviewPaywalls( + retryCount: 2 + ) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true), + factory: dependencyContainer + ) + + #expect(urlRequest?.httpMethod == "GET") + + let urlString = try #require(urlRequest?.url?.absoluteString) + // Host included for the same reason as the resolver's test: the path alone + // would pass against the v1 `baseHost`. + #expect(urlString.contains("api.superwall.com/v2/paywalls/preview-list")) + + // The application comes from the token's scope server-side, so the picker + // must not be sending an id or application_id of its own. + #expect(urlString.contains("?") == false) + + // Same auth as the resolver: the debugger's `sat_` preview token as a + // bearer, not the app's public key. + #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer sat_test") + } + + @Test func paywallPreviewList_decodesIgnoringUndeclaredFields() throws { + // `object`, `has_more` and `application_id` are returned by the endpoint but + // deliberately not declared on the model. Decoding must ignore them rather + // than throw, so a change to those fields can never empty the picker. + let json = """ + { + "object": "list", + "has_more": false, + "application_id": "2889", + "data": [ + { "id": "178725", "identifier": "some-slug", "name": "Some Paywall" } + ] + } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.count == 1) + #expect(list.data.first?.id == "178725") + #expect(list.data.first?.identifier == "some-slug") + #expect(list.data.first?.name == "Some Paywall") + } + + @Test func paywallPreviewList_decodesWhenUndeclaredFieldsAbsent() throws { + // The inverse: `data` alone must decode, so the picker survives the endpoint + // dropping fields the SDK never reads. + let json = """ + { "data": [{ "id": "1", "identifier": "s", "name": "N" }] } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.count == 1) + } + + @Test func paywallPreviewList_decodesEmptyList() throws { + // An app with no previewable paywalls returns an empty `data`. That must + // decode cleanly — `pressedPreview` then declines to open the picker rather + // than the request being treated as a failure. + let json = """ + { "object": "list", "has_more": false, "data": [] } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.isEmpty) + } }