From dbbc43e311371f81a4791215aabf85fed60ae31a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 12:50:10 +0200 Subject: [PATCH 1/8] feat: resolve preview paywall by id instead of fetching all paywalls The debug/preview flow fetched ALL paywalls for the app just to translate the deep-link numeric `paywall_id` into the paywall `identifier`, which is slow and breaks for apps with many paywalls. Add a single-lookup resolver: `GET /v2/paywalls/resolve?id=` on the V2 API returns `{ id, identifier, name }` for a paywall id, scoped to the app's public key. The preview flow now resolves the identifier with one request, then fetches that one paywall exactly as before. - Add `.paywallsV2` endpoint host (base host, `/v2/` prefix) - Add `Endpoint.resolvePaywall(byDatabaseId:)` + `Network.resolvePaywallIdentifier` (authenticated with the public key via `isForDebugging: false`) - Rewrite `DebugViewController` preview to use the resolver; drop the fetch-all - Remove the now-unused `Paywalls` list model and `getPaywalls()` - The "Your Paywalls" multi-paywall picker is removed (it depended on the fetch-all); previewing one paywall by id is unaffected Requires the backend resolver endpoint (superwall/paywall-next#3456). Co-Authored-By: Claude Opus 4.8 --- .../Debug/DebugViewController.swift | 41 +++++++++++-------- .../SuperwallKit/Models/Paywall/Paywall.swift | 21 +++++++++- Sources/SuperwallKit/Network/API.swift | 17 ++++++++ Sources/SuperwallKit/Network/Endpoint.swift | 21 +++++++--- Sources/SuperwallKit/Network/Network.swift | 22 +++++++--- .../Network/NetworkTests.swift | 23 +++++++++++ 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 37a7e31a62..e6b77fdd39 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -118,6 +118,9 @@ final class DebugViewController: UIViewController { var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? + /// Backed the "Your Paywalls" picker. Retained (always empty) now that the + /// preview flow resolves a single paywall by id instead of fetching all of + /// them; see `pressedPreview`. var paywalls: [Paywall] = [] var previewViewContent: UIView? private var cancellable: AnyCancellable? @@ -204,32 +207,31 @@ final class DebugViewController: UIViewController { func loadPreview() async { activityIndicator.startAnimating() previewViewContent?.removeFromSuperview() + await finishLoadingPreview() + } - if paywalls.isEmpty { + func finishLoadingPreview() async { + var paywallId: String? + + 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 } @@ -313,6 +315,11 @@ final class DebugViewController: UIViewController { @objc func pressedPreview() { guard let id = paywallDatabaseId else { return } + // The "Your Paywalls" picker listed every paywall from the (now removed) + // fetch-all. Preview opens one specific paywall by id, so there is no list + // to choose from; bail out rather than present an empty sheet. + guard !paywalls.isEmpty else { return } + let options: [AlertOption] = paywalls.map { paywall in var name = paywall.name diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 3a41557e58..97cbf3019c 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -8,8 +8,25 @@ 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 } struct Paywall: Codable { diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index 495f2572a0..5c391fd0b5 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 } protocol ApiHostConfig { @@ -34,6 +35,7 @@ struct Api { let enrichment: Enrichment let adServices: AdServices let subscriptionsApi: SubscriptionsAPI + let paywallsV2: PaywallsV2 init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { base = Base(networkEnvironment: networkEnvironment) @@ -41,6 +43,7 @@ struct Api { enrichment = Enrichment(networkEnvironment: networkEnvironment) adServices = AdServices(networkEnvironment: networkEnvironment) subscriptionsApi = SubscriptionsAPI(networkEnvironment: networkEnvironment) + paywallsV2 = PaywallsV2(networkEnvironment: networkEnvironment) } func getConfig(host: EndpointHost) -> ApiHostConfig { @@ -55,6 +58,8 @@ struct Api { return adServices case .subscriptionsApi: return subscriptionsApi + case .paywallsV2: + return paywallsV2 } } @@ -109,4 +114,16 @@ struct Api { self.networkEnvironment = networkEnvironment } } + + /// The V2 API served under the `/v2/` prefix on the base host + /// (e.g. `superwall.com/v2/*`, which is routed to the V2 Worker). + struct PaywallsV2: ApiHostConfig { + let networkEnvironment: SuperwallOptions.NetworkEnvironment + var host: String { return networkEnvironment.baseHost } + var path: String { return "/v2/" } + + init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { + self.networkEnvironment = networkEnvironment + } + } } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 7a4b9b9a49..8cea6c0a4a 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -207,15 +207,26 @@ 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 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 ) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index bd863ef451..4c22b39520 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -122,21 +122,31 @@ 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 app's public key (`isForDebugging: false`), which + /// `makeHeaders` sends as `Authorization: Bearer `. + 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 + isForDebugging: false ) ) - return response.paywalls } catch { Logger.debug( logLevel: .error, scope: .network, - message: "Request Failed: /paywalls", + message: "Request Failed: /v2/paywalls/resolve", error: error ) throw error diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index ac9f1fd86f..086163654d 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -179,4 +179,27 @@ 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() + let endpoint = Endpoint.resolvePaywall( + byDatabaseId: "123", + retryCount: 6 + ) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: false), + factory: dependencyContainer + ) + + #expect(urlRequest?.httpMethod == "GET") + + let urlString = try #require(urlRequest?.url?.absoluteString) + #expect(urlString.contains("/v2/paywalls/resolve")) + #expect(urlString.contains("id=123")) + + // The resolver authenticates with the public key (isForDebugging: false), + // so an Authorization header is attached. + #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") != nil) + } } From c6cde4b1d665a7bc41da405060a379085c871ce9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 17:18:08 +0200 Subject: [PATCH 2/8] refactor(debug): remove dead preview-picker path With the fetch-all gone, the "Your Paywalls" multi-paywall picker can no longer be populated, so `pressedPreview()` was unreachable and the picker chip still advertised a dropdown that did nothing. - Remove `pressedPreview()` and the always-empty `paywalls` property - Drop the picker tap target from the name chip and the preview container - Remove the down-arrow affordance; the chip is now a display-only name label (renamed `previewPickerButton` -> `previewNameButton`) Co-Authored-By: Claude Opus 4.8 --- .../Debug/DebugViewController.swift | 63 +++---------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index e6b77fdd39..6391fa404b 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -75,7 +75,7 @@ final class DebugViewController: UIViewController { return button }() - lazy var previewPickerButton: SWBounceButton = { + lazy var previewNameButton: SWBounceButton = { let button = SWBounceButton() button.setTitle("", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14) @@ -86,14 +86,11 @@ final class DebugViewController: UIViewController { button.setTitleColor(primaryColor, for: .normal) button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) button.translatesAutoresizingMaskIntoConstraints = false - button.imageView?.tintColor = primaryColor button.layer.cornerRadius = 10 - - let image = UIImage(named: "SuperwallKit_down_arrow", in: Bundle.module, compatibleWith: nil)! - button.semanticContentAttribute = .forceRightToLeft - button.setImage(image, for: .normal) - button.imageView?.tintColor = primaryColor - button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered) + // Display-only chip showing the previewed paywall's name. The multi-paywall + // picker (a dropdown opened from this chip) was removed when the preview + // flow stopped fetching all paywalls, so it no longer responds to taps. + button.isUserInteractionEnabled = false return button }() @@ -111,17 +108,12 @@ final class DebugViewController: UIViewController { let button = SWBounceButton() button.shouldAnimateLightly = true button.translatesAutoresizingMaskIntoConstraints = false - button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered) return button }() var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? - /// Backed the "Your Paywalls" picker. Retained (always empty) now that the - /// preview flow resolves a single paywall by id instead of fetching all of - /// them; see `pressedPreview`. - var paywalls: [Paywall] = [] var previewViewContent: UIView? private var cancellable: AnyCancellable? private var initialLocaleIdentifier: String? @@ -168,7 +160,7 @@ final class DebugViewController: UIViewController { view.addSubview(consoleButton) view.addSubview(exitButton) view.addSubview(bottomButton) - previewContainerView.addSubview(previewPickerButton) + previewContainerView.addSubview(previewNameButton) view.backgroundColor = lightBackgroundColor previewContainerView.clipsToBounds = false @@ -198,9 +190,9 @@ final class DebugViewController: UIViewController { bottomButton.widthAnchor.constraint(equalTo: view.layoutMarginsGuide.widthAnchor, constant: -40), bottomButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -10), - previewPickerButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0), - previewPickerButton.heightAnchor.constraint(equalToConstant: 26), - previewPickerButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor) + previewNameButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0), + previewNameButton.heightAnchor.constraint(equalToConstant: 26), + previewNameButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor) ]) } @@ -250,7 +242,7 @@ final class DebugViewController: UIViewController { paywall.productVariables = productVariables self.paywall = paywall - self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) + self.previewNameButton.setTitle("\(paywall.name)", for: .normal) self.activityIndicator.stopAnimating() self.addPaywallPreview() } catch { @@ -312,41 +304,6 @@ final class DebugViewController: UIViewController { } } - @objc func pressedPreview() { - guard let id = paywallDatabaseId else { return } - - // The "Your Paywalls" picker listed every paywall from the (now removed) - // fetch-all. Preview opens one specific paywall by id, so there is no list - // to choose from; bail out rather than present an empty sheet. - guard !paywalls.isEmpty else { return } - - let options: [AlertOption] = paywalls.map { paywall in - var name = paywall.name - - if id == paywall.databaseId { - name = "\(name) ✓" - } - - let alert = AlertOption( - title: name, - action: { [weak self] in - self?.paywallDatabaseId = paywall.databaseId - self?.paywallIdentifier = paywall.identifier - Task { await self?.loadPreview() } - }, - style: .default - ) - return alert - } - - presentAlert( - title: nil, - message: "Your Paywalls", - options: options, - on: previewPickerButton - ) - } - @objc func pressedExitButton() { Task { await debugManager.closeDebugger(animated: false) From b073ddabe39d42a21c9d090c612984bc84a55769 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 17:23:04 +0200 Subject: [PATCH 3/8] fix(network): point V2 resolver at api.superwall.com, not baseHost The V2 API is served from the `superwall.com` domain (api.superwall.com in production, api.superwall.dev in developer), which is a DIFFERENT apex domain than `baseHost` (the legacy v1 API on api.superwall.me). Using baseHost would have sent the resolver to api.superwall.me/v2/... in production and 404'd. Add a dedicated `NetworkEnvironment.apiV2Host` (mirroring `enrichmentHost`, another superwall.com-domain service) and point the `PaywallsV2` host config at it. Local uses localhost:3001 (the apps/api wrangler dev port). Co-Authored-By: Claude Opus 4.8 --- .../Config/Options/SuperwallOptions.swift | 18 ++++++++++++++++++ Sources/SuperwallKit/Network/API.swift | 7 ++++--- 2 files changed, 22 insertions(+), 3 deletions(-) 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/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index df0797e730..d8a45fd318 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -120,11 +120,12 @@ struct Api { } } - /// The V2 API served under the `/v2/` prefix on the base host - /// (e.g. `superwall.com/v2/*`, which is routed to the V2 Worker). + /// 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.baseHost } + var host: String { return networkEnvironment.apiV2Host } var path: String { return "/v2/" } init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { From fb00fc48d7eaffd6d345fa8fa3d4d987b8149bed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 05:44:00 +0000 Subject: [PATCH 4/8] fix: resolve paywall preview via debugger signed token instead of app key --- CHANGELOG.md | 6 ++++++ Sources/SuperwallKit/Network/Endpoint.swift | 4 +++- Sources/SuperwallKit/Network/Network.swift | 8 +++++--- Tests/SuperwallKitTests/Network/NetworkTests.swift | 10 ++++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d06cfe70fa..41643aad40 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/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index e94dc45ff3..994f53664a 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -216,7 +216,9 @@ extension Endpoint where /// /// 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 app's public key (see `Network.resolvePaywallIdentifier`). + /// 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 diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 9ea852a18a..0846fe6ba5 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -125,8 +125,10 @@ class Network { /// 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 app's public key (`isForDebugging: false`), which - /// `makeHeaders` sends as `Authorization: Bearer `. + /// 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 @@ -139,7 +141,7 @@ class Network { ), data: SuperwallRequestData( factory: factory, - isForDebugging: false + isForDebugging: true ) ) } catch { diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index 086163654d..0eee86c92f 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -182,13 +182,14 @@ struct NetworkTests { @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: false), + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true), factory: dependencyContainer ) @@ -198,8 +199,9 @@ struct NetworkTests { #expect(urlString.contains("/v2/paywalls/resolve")) #expect(urlString.contains("id=123")) - // The resolver authenticates with the public key (isForDebugging: false), - // so an Authorization header is attached. - #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") != nil) + // 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") } } From d2aa38988cff08d358323c7603e06cbb9525afe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:23:14 +0200 Subject: [PATCH 5/8] feat(debug): restore the "Your Paywalls" picker via the preview-list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the multi-paywall picker this branch removed in c6cde4b1d6, without reinstating the fetch-all that made it removable. The picker let you switch previews from inside the debugger. It was backed by the SDK fetching every paywall in full; when that went away the array could never be populated, `pressedPreview()` was unreachable behind a `guard !paywalls.isEmpty`, and the chip advertised a dropdown that did nothing — so it was deleted. Without it, previewing a different paywall means going back to the dashboard for a fresh QR code. paywall-next#3657 adds `GET /v2/paywalls/preview-list`, which returns id/identifier/name for the non-archived paywalls of the application in the debugger's `sat_` preview token — no presentable paywall JSON. That is enough to render the picker at a fraction of the old payload, fetched on demand rather than on every debugger launch. - `PaywallPreviewList` / `PaywallPreviewListItem` decodables - `Endpoint.listPreviewPaywalls` on the `.paywallsV2` host - `Network.listPreviewPaywalls`, same `isForDebugging: true` auth as the resolver, with a lower retry count since it only feeds an optional picker - Restores `previewPickerButton` (name, down arrow, tap target) and `pressedPreview`, now keyed off `previewPaywalls` The list loads after the previewed paywall is on screen, so the picker never delays what the user asked for, and a failure degrades to an empty picker rather than an error. `pressedPreview` needs more than one entry before opening — an action sheet offering only the paywall already on screen is noise. Co-Authored-By: Claude Opus 5 --- .../Debug/DebugViewController.swift | 87 ++++++++++++++++--- .../SuperwallKit/Models/Paywall/Paywall.swift | 33 +++++++ Sources/SuperwallKit/Network/Endpoint.swift | 25 ++++++ Sources/SuperwallKit/Network/Network.swift | 30 +++++++ 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 6391fa404b..1f79308474 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -75,7 +75,7 @@ final class DebugViewController: UIViewController { return button }() - lazy var previewNameButton: SWBounceButton = { + lazy var previewPickerButton: SWBounceButton = { let button = SWBounceButton() button.setTitle("", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14) @@ -86,11 +86,14 @@ final class DebugViewController: UIViewController { button.setTitleColor(primaryColor, for: .normal) button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) button.translatesAutoresizingMaskIntoConstraints = false + button.imageView?.tintColor = primaryColor button.layer.cornerRadius = 10 - // Display-only chip showing the previewed paywall's name. The multi-paywall - // picker (a dropdown opened from this chip) was removed when the preview - // flow stopped fetching all paywalls, so it no longer responds to taps. - button.isUserInteractionEnabled = false + + let image = UIImage(named: "SuperwallKit_down_arrow", in: Bundle.module, compatibleWith: nil)! + button.semanticContentAttribute = .forceRightToLeft + button.setImage(image, for: .normal) + button.imageView?.tintColor = primaryColor + button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered) return button }() @@ -108,12 +111,18 @@ final class DebugViewController: UIViewController { let button = SWBounceButton() button.shouldAnimateLightly = true button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered) return button }() var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: 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? @@ -160,7 +169,7 @@ final class DebugViewController: UIViewController { view.addSubview(consoleButton) view.addSubview(exitButton) view.addSubview(bottomButton) - previewContainerView.addSubview(previewNameButton) + previewContainerView.addSubview(previewPickerButton) view.backgroundColor = lightBackgroundColor previewContainerView.clipsToBounds = false @@ -190,9 +199,9 @@ final class DebugViewController: UIViewController { bottomButton.widthAnchor.constraint(equalTo: view.layoutMarginsGuide.widthAnchor, constant: -40), bottomButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -10), - previewNameButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0), - previewNameButton.heightAnchor.constraint(equalToConstant: 26), - previewNameButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor) + previewPickerButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0), + previewPickerButton.heightAnchor.constraint(equalToConstant: 26), + previewPickerButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor) ]) } @@ -242,9 +251,10 @@ final class DebugViewController: UIViewController { paywall.productVariables = productVariables self.paywall = paywall - self.previewNameButton.setTitle("\(paywall.name)", for: .normal) + self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) self.activityIndicator.stopAnimating() self.addPaywallPreview() + await self.loadPreviewPaywalls() } catch { Logger.debug( logLevel: .error, @@ -304,6 +314,63 @@ final class DebugViewController: UIViewController { } } + /// Populates the "Your Paywalls" picker from the application in the debugger's + /// preview token. + /// + /// Runs after the previewed paywall is on screen so the picker never delays + /// the thing the user actually asked for. Best-effort: a failure leaves + /// `previewPaywalls` empty and `pressedPreview` simply 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 + ) + } + } + + @objc func pressedPreview() { + guard let id = paywallDatabaseId else { return } + + // Empty when the list request failed. Single-entry when the app has one + // paywall — an action sheet offering only what's already on screen is noise, + // so bail rather than present it. + guard previewPaywalls.count > 1 else { return } + + let options: [AlertOption] = previewPaywalls.map { paywall in + var name = paywall.name + + if id == paywall.id { + name = "\(name) ✓" + } + + let alert = AlertOption( + title: name, + action: { [weak self] in + self?.paywallDatabaseId = paywall.id + self?.paywallIdentifier = paywall.identifier + Task { await self?.loadPreview() } + }, + style: .default + ) + return alert + } + + presentAlert( + title: nil, + message: "Your Paywalls", + options: options, + on: previewPickerButton + ) + } + @objc func pressedExitButton() { Task { await debugManager.closeDebugger(animated: false) diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index a4a128e087..430cba830b 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -29,6 +29,39 @@ struct PaywallIdentifierResolution: Decodable { 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` +/// and `application_id`, which are not needed here and are ignored. +struct PaywallPreviewList: Decodable { + /// The paywalls available to preview, capped server-side. + let data: [PaywallPreviewListItem] + + /// True when the application has more paywalls than the returned cap. + let hasMore: Bool +} + struct Paywall: Codable { /// The id of the paywall in the database. var databaseId: String diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 994f53664a..304f686051 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -235,6 +235,31 @@ extension Endpoint where } } +// 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 + ) + } +} + // MARK: - ConfigResponse extension Endpoint where Kind == EndpointKinds.Superwall, diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 0846fe6ba5..38f6b9488a 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -155,6 +155,36 @@ class Network { } } + /// 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 + ) + ) + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /v2/paywalls/preview-list", + error: error + ) + throw error + } + } + func getConfig( injectedApplicationStatePublisher: (AnyPublisher)? = nil, maxRetry: Int? = nil, From b708cdaf7d441d0547a283fd1f1442ddd267f53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:44:07 +0200 Subject: [PATCH 6/8] fix(debug): load the paywall picker independently of the preview render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the picker restoration. `hasMore` was a non-optional `Bool` that nothing read. The decoder only applies `convertFromSnakeCase`, so if `preview-list` ever stopped sending `has_more` the whole list decode would throw `keyNotFound` and the picker would silently empty behind a `.warn`. The SDK does not paginate, so the field is dropped rather than made optional — no reason to carry a decode dependency on something unused. `loadPreviewPaywalls()` sat at the end of the render success path, and both the resolve `catch` and the fetch `catch` return before reaching it. The picker therefore only populated when the paywall loaded, leaving the down-arrow inert exactly when switching away is most useful — the case the picker exists for. It now runs from `viewDidLoad` alongside the preview load, concurrently and independently, so a failed render still offers alternatives. Calling it only from `viewDidLoad` also means switching paywalls via the picker no longer refetches a list that cannot have changed. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Debug/DebugViewController.swift | 15 ++++++++++----- Sources/SuperwallKit/Models/Paywall/Paywall.swift | 11 ++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 1f79308474..6af72f3c61 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -160,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() { @@ -254,7 +260,6 @@ final class DebugViewController: UIViewController { self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) self.activityIndicator.stopAnimating() self.addPaywallPreview() - await self.loadPreviewPaywalls() } catch { Logger.debug( logLevel: .error, @@ -317,10 +322,10 @@ final class DebugViewController: UIViewController { /// Populates the "Your Paywalls" picker from the application in the debugger's /// preview token. /// - /// Runs after the previewed paywall is on screen so the picker never delays - /// the thing the user actually asked for. Best-effort: a failure leaves - /// `previewPaywalls` empty and `pressedPreview` simply declines to open, which - /// is the behaviour before this was restored. + /// 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() diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 430cba830b..cb7a0fda19 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -52,14 +52,15 @@ struct PaywallPreviewListItem: Decodable { /// 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` -/// and `application_id`, which are not needed here and are ignored. +/// 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] - - /// True when the application has more paywalls than the returned cap. - let hasMore: Bool } struct Paywall: Codable { From 9260802ec8f97a31e5d23077a765af86db2ca896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:54:54 +0200 Subject: [PATCH 7/8] test(network): cover the preview-list endpoint and its decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the gap left by the picker restoration: `listPreviewPaywalls` had no test, while `resolvePaywall` did. - `listPreviewPaywalls_endpointBuildsRequest` mirrors the resolver's test — GET, correct path, and the `sat_` debug key as the bearer. Also asserts the URL carries no query string: the application is taken from the token's scope server-side, so a client-supplied `application_id` would be a way around that scoping. - Three decoding tests around `PaywallPreviewList`, which declares only `data` while the endpoint also returns `object`, `has_more` and `application_id`. They pin both directions — undeclared fields present and absent — plus the empty-list case, so the response shape can change without a `keyNotFound` silently emptying the picker. Co-Authored-By: Claude Opus 5 --- .../Network/NetworkTests.swift | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index 0eee86c92f..2fefbcc78f 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -204,4 +204,78 @@ struct NetworkTests { // `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) + #expect(urlString.contains("/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) + } } From b8ea624e4b56d1ee5cc40d65f10f8eaf75b9dc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:15:53 +0200 Subject: [PATCH 8/8] fix(debug): open the picker when no paywall rendered; pin host in endpoint tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more review findings. `pressedPreview` still bailed on `guard let id = paywallDatabaseId`, which was the last thing keeping the picker inert in the case b708cdaf7 set out to fix: a debug deep link without a `paywall_id` leaves it nil, so nothing renders, but the now-independent list load populates `previewPaywalls` and the picker refused to open anyway. The id was only used to mark the current row with a checkmark, so it never needed to be non-nil. Replaces both that guard and the `count > 1` check with a single condition — open when the list contains something other than what is already on screen. That still declines on an empty list and on a single entry matching the current paywall, while opening when nothing rendered. The endpoint tests matched only the path, so they passed regardless of which host resolved — exactly how the V2 resolver shipped pointing at the v1 `baseHost` (b073dda). Both now assert host and path together. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Debug/DebugViewController.swift | 16 +++++++++------- .../SuperwallKitTests/Network/NetworkTests.swift | 9 +++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 6af72f3c61..d371b1bd4a 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -342,17 +342,19 @@ final class DebugViewController: UIViewController { } @objc func pressedPreview() { - guard let id = paywallDatabaseId else { return } - - // Empty when the list request failed. Single-entry when the app has one - // paywall — an action sheet offering only what's already on screen is noise, - // so bail rather than present it. - guard previewPaywalls.count > 1 else { return } + // 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.id { + // 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) ✓" } diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index 2fefbcc78f..bf90bab689 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -196,7 +196,10 @@ struct NetworkTests { #expect(urlRequest?.httpMethod == "GET") let urlString = try #require(urlRequest?.url?.absoluteString) - #expect(urlString.contains("/v2/paywalls/resolve")) + // 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 @@ -220,7 +223,9 @@ struct NetworkTests { #expect(urlRequest?.httpMethod == "GET") let urlString = try #require(urlRequest?.url?.absoluteString) - #expect(urlString.contains("/v2/paywalls/preview-list")) + // 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.