-
Notifications
You must be signed in to change notification settings - Fork 1
Add checkout diagnostics for Swift #642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDiagnostics.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import Foundation | ||
|
|
||
| /// An SDK diagnostic that applications may observe for integration telemetry. | ||
| /// | ||
| /// Diagnostics are informational and never replace checkout lifecycle, preload, | ||
| /// or protocol events. Applications may safely ignore every diagnostic event. | ||
| public enum CheckoutDiagnosticEvent: Equatable, Sendable { | ||
| /// An incoming checkout message was denied before protocol dispatch. | ||
| case messageRejected(CheckoutMessageRejection) | ||
| } | ||
|
|
||
| /// Details about an incoming checkout message denied by the ingress policy. | ||
| /// | ||
| /// The raw message body is intentionally omitted because rejected input is | ||
| /// untrusted and may contain sensitive or arbitrarily large data. | ||
| public struct CheckoutMessageRejection: Equatable, Sendable { | ||
| /// The origin the message was received from, for example `https://example.com`. | ||
| public let origin: String | ||
|
|
||
| /// The stable reason the message was denied. | ||
| public let reason: Reason | ||
|
|
||
| package init(origin: String, reason: Reason) { | ||
| self.origin = origin | ||
| self.reason = reason | ||
| } | ||
|
|
||
| public enum Reason: Equatable, Sendable { | ||
| /// The message was sent from a child frame rather than the checkout's main frame. | ||
| case childFrame | ||
|
|
||
| /// The message origin used explicit port zero while origin validation was enabled. | ||
| case unsupportedPort | ||
|
|
||
| /// The message origin did not match the effective allowlist. | ||
| case originNotAllowed | ||
| } | ||
| } | ||
|
|
||
| /// SDK-wide diagnostic events emitted by Checkout Kit. | ||
| /// | ||
| /// Subscriptions are hot and do not replay earlier events. Subscribe before | ||
| /// calling `preload(checkout:)` when preload diagnostics are required. Listeners | ||
| /// are called on the main actor so diagnostics follow the same observation model | ||
| /// as checkout preload state. | ||
| public final class CheckoutDiagnostics: Sendable { | ||
| /// A retained observation of SDK diagnostic events. | ||
| /// | ||
| /// Keep the subscription for as long as diagnostics should be observed. | ||
| /// Observation stops when the subscription is cancelled or released. | ||
| @MainActor | ||
| public final class Subscription { | ||
| private var listener: (@MainActor (CheckoutDiagnosticEvent) -> Void)? | ||
|
|
||
| fileprivate init(listener: @escaping @MainActor (CheckoutDiagnosticEvent) -> Void) { | ||
| self.listener = listener | ||
| } | ||
|
|
||
| /// Stops this listener from receiving future diagnostic events. | ||
| public func cancel() { | ||
| listener = nil | ||
| } | ||
|
|
||
| fileprivate func receive(_ event: CheckoutDiagnosticEvent) { | ||
| listener?(event) | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| private final class WeakSubscription { | ||
| weak var value: Subscription? | ||
|
|
||
| init(_ value: Subscription) { | ||
| self.value = value | ||
| } | ||
| } | ||
|
|
||
| @MainActor private var subscriptions = [UUID: WeakSubscription]() | ||
|
|
||
| package init() {} | ||
|
|
||
| /// Subscribes a listener to future diagnostic events. | ||
| /// | ||
| /// Retain the returned subscription for as long as events should be observed. | ||
| @MainActor | ||
| public func subscribe( | ||
| _ listener: @escaping @MainActor (CheckoutDiagnosticEvent) -> Void | ||
| ) -> Subscription { | ||
| subscriptions = subscriptions.filter { $0.value.value != nil } | ||
|
|
||
| let subscription = Subscription(listener: listener) | ||
| subscriptions[UUID()] = WeakSubscription(subscription) | ||
| return subscription | ||
| } | ||
|
|
||
| @MainActor | ||
| package func emit(_ event: CheckoutDiagnosticEvent) { | ||
| log(event) | ||
|
|
||
| // Retain a snapshot for this delivery so listeners may cancel themselves | ||
| // or release other subscriptions without mutating the traversed collection. | ||
| let currentSubscriptions = subscriptions.compactMap { $0.value.value } | ||
| subscriptions = subscriptions.filter { $0.value.value != nil } | ||
| for subscription in currentSubscriptions { | ||
| subscription.receive(event) | ||
| } | ||
| } | ||
|
|
||
| private func log(_ event: CheckoutDiagnosticEvent) { | ||
| switch event { | ||
| case let .messageRejected(rejection): | ||
| OSLogger.shared.debug( | ||
| "Rejected checkout message from \(rejection.origin): \(rejection.reason.logDescription)" | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension CheckoutMessageRejection.Reason { | ||
| fileprivate var logDescription: String { | ||
| switch self { | ||
| case .childFrame: | ||
| return "message was sent from a child frame" | ||
| case .unsupportedPort: | ||
| return "origin uses unsupported port 0" | ||
| case .originNotAllowed: | ||
| return "origin is not in the allowlist" | ||
| } | ||
| } | ||
| } | ||
59 changes: 59 additions & 0 deletions
59
platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageIngressPolicy.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import Foundation | ||
|
|
||
| /// Transport metadata available before an incoming message enters protocol dispatch. | ||
| /// | ||
| /// WebKit owns the authoritative frame and origin metadata. Keeping that metadata | ||
| /// separate from the untrusted message body prevents protocol handlers from being | ||
| /// responsible for transport admission decisions. Origin details are resolved lazily | ||
| /// because open-by-default admission does not need to inspect them. | ||
| struct IncomingCheckoutMessage { | ||
| let body: String | ||
| let isMainFrame: Bool | ||
| let resolveOrigin: () -> MessageOrigin | ||
| let resolveRequestURL: () -> URL? | ||
| } | ||
|
|
||
| /// Applies the SDK's admission rules to incoming checkout messages. | ||
| /// | ||
| /// A message may be valid JSON and valid checkout protocol while still being | ||
| /// rejected by this policy because its transport metadata is not admitted. | ||
| struct CheckoutMessageIngressPolicy { | ||
| enum Decision: Equatable { | ||
| case accepted | ||
| case rejected(CheckoutMessageRejection) | ||
| } | ||
|
|
||
| let configuredOrigins: [String] | ||
| let checkoutURL: URL? | ||
|
|
||
| func evaluate(_ message: IncomingCheckoutMessage) -> Decision { | ||
| guard message.isMainFrame else { | ||
| return .rejected( | ||
| CheckoutMessageRejection(origin: message.resolveOrigin().description, reason: .childFrame) | ||
| ) | ||
| } | ||
|
|
||
| let patterns = MessageOriginValidator.effectiveAllowlist( | ||
| configuredOrigins: configuredOrigins, | ||
| checkoutURL: checkoutURL | ||
| ) | ||
| guard let patterns else { return .accepted } | ||
|
|
||
| // WKSecurityOrigin reports both the default port and explicit port zero | ||
| // as zero. The frame request URL preserves the explicit spelling. | ||
| guard message.resolveRequestURL()?.port != 0 else { | ||
| return .rejected( | ||
| CheckoutMessageRejection(origin: message.resolveOrigin().description, reason: .unsupportedPort) | ||
| ) | ||
| } | ||
|
|
||
| let origin = message.resolveOrigin() | ||
| guard MessageOriginValidator.isAllowed(origin: origin, patterns: patterns) else { | ||
| return .rejected( | ||
| CheckoutMessageRejection(origin: origin.description, reason: .originNotAllowed) | ||
| ) | ||
| } | ||
|
|
||
| return .accepted | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 0 additions & 17 deletions
17
platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was tempted to implement an
AsyncStreamhere instead but decided to align with the existing subscribe pattern for preload observability. Also, implementing async streams in Kotlin required the coroutines dependency.