Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,47 @@
- reusable SwiftUI plan, top-up, usage, balance, and balance-history screens;
- a section-selectable paywall with a host-app supplied SwiftUI header.

The package supports iOS 16+, macOS 13+, and Swift 5.9+.
The package requires iOS 26+, macOS 26+, and Swift 5.9+.

## Add the package

In Xcode, choose **File → Add Package Dependencies → Add Local…** and select this folder. Import the library where it is used:
```swift
.package(url: "https://github.com/rxtech-lab/RxSubscriptionIOS.git", from: "0.2.0")
```

Or in Xcode, **File → Add Package Dependencies…** with that URL. Import the library where it is used:

```swift
import RxSubscriptionIOS
```

## Configure a client

Create one client for the currently signed-in RxLab user. The API key controls whether the server uses sandbox or production data.
Create one client for the currently signed-in RxLab user. The key controls whether the server uses sandbox or production data.

Which initializer you want depends on the kind of key you hold. **In an app, use a publishable key.**

### Publishable key — for apps

A publishable key is safe to ship inside a binary because it does nothing on its own. Every request also carries the signed-in user's rxlab access token, and the server acts only for whoever that token identifies — so a key lifted out of your app grants an attacker nothing they did not already have.

```swift
let subscriptions = Client(
serverURL: URL(string: "https://subscription.example.com")!,
publishableKey: configuration.subscriptionPublishableKey,
rxlabUserID: session.userID,
email: session.email,
userToken: { forceRefresh in
try await session.accessToken(forceRefresh: forceRefresh)
}
)
```

The `userToken` closure is called before every request, and called again with `forceRefresh: true` if the server rejects the token — so an access token that expired while a screen sat open recovers without the user noticing. Your app's existing session machinery stays the only thing that knows how to refresh.

Publishable keys reach the read and purchase endpoints: catalog, entitlements, usage, balances, ledger, consumption, invoices, purchases, coupon validation, checkout, and the App Store bridge. Crediting a balance, recording usage, and the whole reservation family answer `403 insufficient_key_scope` — those belong on a server.

### Secret key — for servers

```swift
let subscriptions = Client(
Expand All @@ -31,7 +59,7 @@ let subscriptions = Client(
)
```

Do not place an unrestricted or unrelated server credential in an app bundle. Mobile app secrets can be extracted. Use a dedicated, revocable application key for this backend contract and rotate it if the app is compromised.
A secret key reaches every endpoint and names whichever user it likes, so it must never ship in an app bundle — mobile app secrets can be extracted, and this one can credit any balance for any user.

## SwiftUI views

Expand Down Expand Up @@ -97,6 +125,16 @@ let outcome = try await subscriptions.purchaseApple(
let restored = try await subscriptions.restoreApplePurchases()
```

Start `observeTransactionUpdates()` once at launch and hold the task for the lifetime of the session:

```swift
transactionObserver = subscriptions.observeTransactionUpdates { _ in
Task { await store.refresh() }
}
```

Renewals, Ask-to-Buy approvals, purchases made on another device, and interrupted flows all arrive on `Transaction.updates` rather than as the result of `purchaseApple`. Without an observer they are never finished, so StoreKit re-delivers them on every launch. The backend still learns about them from App Store Server Notifications either way; this just keeps the app in step.

Enable the **In-App Purchase** capability in the containing app target. Products and Notifications V2 still need to be configured in App Store Connect and in the RxSubscription console.

## Client API
Expand All @@ -112,7 +150,7 @@ The client covers the backend's complete public API surface.
| Stripe | `checkoutPlan`, `checkoutTopUp`, `billingPortal`, `validateCoupon`, `invoices` |
| Purchases | `purchases` |
| App Store bridge | `appleAccountToken`, `setAppleConsumptionConsent`, `submitAppleTransaction` |
| StoreKit | `storeProducts`, `purchaseApple`, `restoreApplePurchases` |
| StoreKit | `storeProducts`, `purchaseApple`, `restoreApplePurchases`, `observeTransactionUpdates` |

Metadata values use the package's `JSONValue` type. Balance and usage mutations preserve the server's idempotency contract. In particular, supply stable idempotency keys when retrying balance mutations or reservation operations.

Expand Down Expand Up @@ -145,4 +183,4 @@ The backend returns usage denials with HTTP 402. `recordUsage` decodes those res
swift test
```

The test suite verifies authentication and user scoping, StoreKit catalog decoding, error payloads, HTTP 402 usage behavior, date handling, formatting, and request/response coverage for every public backend route.
The test suite verifies authentication and user scoping, StoreKit catalog decoding, error payloads, HTTP 402 usage behavior, date handling, formatting, and request/response coverage for every public backend route. It also covers the publishable-key path: that both credentials are sent, that a 401 triggers exactly one refreshed retry and no more, that a secret-key client is never retried, and that a failed token lookup surfaces as `ClientError.userTokenUnavailable` rather than as a network error.
102 changes: 95 additions & 7 deletions Sources/RxSubscriptionIOS/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ public struct UserIdentity: Hashable, Sendable {
}
}

/// Hands the client a currently valid rxlab access token for the signed-in user.
///
/// Called before every request made with a publishable key, and called again
/// with `forceRefresh: true` if the server rejects the token, so the app's own
/// refresh machinery stays the single owner of the session.
public typealias UserTokenProvider = @Sendable (_ forceRefresh: Bool) async throws -> String

public enum ClientError: Error, LocalizedError {
case invalidConfiguration(String)
case invalidURL
case invalidResponse
case server(statusCode: Int, payload: APIErrorPayload?, responseBody: String?)
case storeProductNotFound(String)
case unverifiedStoreTransaction
case userTokenUnavailable(any Error)

public var errorDescription: String? {
switch self {
Expand All @@ -29,6 +37,8 @@ public enum ClientError: Error, LocalizedError {
return payload?.errorDescription ?? payload?.error ?? body ?? "The subscription request failed."
case .storeProductNotFound(let id): return "App Store product not found: \(id)"
case .unverifiedStoreTransaction: return "StoreKit could not verify the transaction on this device."
case .userTokenUnavailable(let error):
return "Could not read the signed-in user's session: \(error.localizedDescription)"
}
}
}
Expand All @@ -37,6 +47,18 @@ public enum ClientError: Error, LocalizedError {
///
/// Create one client for the signed-in user and pass it directly to the package's
/// SwiftUI views. The backend API key determines sandbox versus production.
///
/// Which initializer you use depends on the kind of key you hold:
///
/// - ``init(serverURL:publishableKey:rxlabUserID:email:displayName:userToken:session:)``
/// is the one an app wants. A publishable key is safe to ship in a binary
/// because it does nothing on its own: every request also carries the
/// signed-in user's access token, and the server acts only for whoever that
/// token identifies.
/// - ``init(serverURL:apiKey:rxlabUserID:email:displayName:session:)`` takes a
/// secret key, which reaches every endpoint and names its own user. That
/// belongs on a server. Shipping one inside an app lets anyone who extracts
/// it grant themselves anything.
@MainActor
public final class Client {
public let serverURL: URL
Expand All @@ -46,7 +68,12 @@ public final class Client {
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let userTokenProvider: UserTokenProvider?

/// Creates a client backed by a secret, server-to-server API key.
///
/// - Warning: A secret key must not ship inside an application binary. Use
/// a publishable key for that.
public init(
serverURL: URL,
apiKey: String,
Expand All @@ -65,6 +92,40 @@ public final class Client {
self.session = session
self.encoder = JSONEncoder()
self.decoder = Self.makeDecoder()
self.userTokenProvider = nil
}

/// Creates a client backed by a publishable key and the signed-in user's session.
///
/// `rxlabUserID` is still sent so requests keep their existing shape, but
/// the server no longer takes the app's word for it: the user comes from
/// the access token, and a request naming somebody else is refused. Pass
/// the same id the token was issued for.
///
/// - Parameter userToken: Returns a currently valid access token. It is
/// called again with `forceRefresh: true` if the server rejects the
/// token, so a session that expired mid-screen recovers without the user
/// noticing.
public init(
serverURL: URL,
publishableKey: String,
rxlabUserID: String,
email: String? = nil,
displayName: String? = nil,
userToken: @escaping UserTokenProvider,
session: URLSession = .shared
) {
self.serverURL = serverURL
self.apiKey = publishableKey
self.user = UserIdentity(
rxlabUserID: rxlabUserID,
email: email,
displayName: displayName
)
self.session = session
self.encoder = JSONEncoder()
self.decoder = Self.makeDecoder()
self.userTokenProvider = userToken
}

// MARK: Storefront and entitlements
Expand Down Expand Up @@ -479,22 +540,49 @@ public final class Client {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}

let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw ClientError.invalidResponse
var (data, http) = try await send(request, refreshingUserToken: false)

// A publishable key's request is only as good as the token it carries.
// An access token that expired while a screen sat open is the ordinary
// case, not an error to surface, so ask for a fresh one and try once
// more. Anything still failing after that is the caller's to handle.
if http.statusCode == 401,
!acceptedStatusCodes.contains(401),
userTokenProvider != nil {
(data, http) = try await send(request, refreshingUserToken: true)
}

guard acceptedStatusCodes.contains(http.statusCode) else {
throw ClientError.server(
statusCode: http.statusCode,
payload: try? decoder.decode(APIErrorPayload.self, from: data),
responseBody: String(data: data, encoding: .utf8)
)
}
do {
return try decoder.decode(Response.self, from: data)
} catch {
throw error
return try decoder.decode(Response.self, from: data)
}

/// Attaches the user token, if this client carries one, and dispatches.
private func send(
_ request: URLRequest,
refreshingUserToken: Bool
) async throws -> (Data, HTTPURLResponse) {
var request = request
if let userTokenProvider {
let token: String
do {
token = try await userTokenProvider(refreshingUserToken)
} catch {
throw ClientError.userTokenUnavailable(error)
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}

let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw ClientError.invalidResponse
}
return (data, http)
}

private func url(for path: String) -> URL {
Expand Down
2 changes: 1 addition & 1 deletion Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// RxSubscriptionIOS provides a typed backend client, StoreKit 2 fulfillment,
/// and ready-to-use SwiftUI subscription, top-up, usage, and balance views.
public enum RxSubscriptionIOS {
public static let version = "0.1.0"
public static let version = "0.2.0"
}
39 changes: 39 additions & 0 deletions Sources/RxSubscriptionIOS/StoreKitSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,45 @@ public extension Client {
}
}

/// Forwards StoreKit transactions that arrive outside a purchase call.
///
/// Renewals, Ask-to-Buy approvals, purchases made on another device, and
/// anything interrupted mid-flight all land on `Transaction.updates` rather
/// than as the result of ``purchaseApple(productID:quantity:)``. Without an
/// observer they are never finished, so StoreKit re-delivers them on every
/// launch and the app's own view of the entitlement lags.
///
/// The server is still the authority — App Store Server Notifications reach
/// it whether or not the app is running — so a submission that fails here
/// is logged past rather than retried: the transaction stays unfinished and
/// StoreKit will offer it again.
///
/// Start this once, early, and hold the returned task for the lifetime of
/// the session; cancelling it stops the observation.
///
/// - Parameter onFulfillment: Called on the main actor after each accepted
/// transaction, so a store can refresh its cached balance.
func observeTransactionUpdates(
onFulfillment: (@MainActor (AppleFulfillment) -> Void)? = nil
) -> Task<Void, Never> {
Task { [weak self] in
for await verification in Transaction.updates {
guard let self else { return }
guard case .verified(let transaction) = verification else { continue }
do {
let fulfillment = try await self.submitAppleTransaction(
verification.jwsRepresentation
)
await transaction.finish()
onFulfillment?(fulfillment)
} catch {
// Deliberately left unfinished — see above.
continue
}
}
}
}

/// Presents Apple's restore sheet, reconciles every current entitlement, and finishes it.
@discardableResult
func restoreApplePurchases() async throws -> [AppleFulfillment] {
Expand Down
Loading