crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PushClient.swift (27394B)


      1 import CryptoKit
      2 import Foundation
      3 
      4 /// Uploads this device's APNs token to the Crossmate push worker and keeps
      5 /// the registration in sync with the current iCloud authorID. The worker
      6 /// itself is idempotent — re-posting an unchanged triple is a no-op — so the
      7 /// only client-side state is a small dedup cache to avoid redundant network
      8 /// chatter on every cold launch.
      9 @MainActor
     10 final class PushClient {
     11     enum Environment: String {
     12         case sandbox
     13         case production
     14     }
     15 
     16     private let baseURL: URL
     17     private let deviceID: String
     18     private let environment: Environment
     19     private let session: URLSession
     20     private let log: (String) -> Void
     21     private let authenticator: PushRequestAuthenticator
     22 
     23     private var apnsToken: String?
     24     private var authorID: String?
     25     /// The addresses this device should be reachable at, each bound to its
     26     /// game's shared push credential. One per shared game the account
     27     /// participates in, plus the account-scoped sibling address (nil
     28     /// credential). The worker keys game addresses under their `credID`.
     29     private var bindings: Set<PushAddressBinding> = []
     30     /// Push kinds this device has muted in notification settings. Registered
     31     /// with the worker as a denylist so muted pushes are dropped before APNs.
     32     private var mutedKinds: Set<String> = []
     33     private var lastRegistered: Registration?
     34 
     35     /// Resolves (minting if needed) the shared push credential for a game.
     36     /// Set by AppServices to read from the GameStore. Publishes use it to sign
     37     /// the request the worker verifies against the credential it holds.
     38     var gameCredentialResolver: (@MainActor (UUID) -> GamePushCredentials?)?
     39 
     40     /// Resolves (minting if needed) the per-game content key used to encrypt the
     41     /// structured payload. Set by AppServices to read from the GameStore. When
     42     /// nil (or unresolved), a publish ships its generic cleartext body with no
     43     /// encrypted payload — degraded but never leaking personal text.
     44     var contentKeyResolver: (@MainActor (UUID) -> SymmetricKey?)?
     45 
     46     /// The cleartext alert body the worker forwards to APNs in place of the real
     47     /// notification text. The personal wording is encrypted into the payload and
     48     /// recomposed on the device by the notification service extension; this is
     49     /// all the worker (and a recipient whose NSE can't decrypt) ever sees.
     50     static let genericAlertBody = "New activity in one of your puzzles"
     51 
     52     /// Game credentials already registered with the worker this session
     53     /// (credID → secret), so `/games/:credID/register` is posted at most once
     54     /// per credential value.
     55     private var registeredGameCredentials: [UUID: String] = [:]
     56 
     57     /// The `(token, bindings, mutedKinds)` triple last successfully reconciled
     58     /// with the worker. All must match for a reconcile to be a no-op, so a
     59     /// token rotation re-binds every address, a binding change (including a
     60     /// credID rotation) registers the delta, and a notification-preference
     61     /// change re-registers every address with the new denylist.
     62     private struct Registration: Equatable {
     63         let token: String
     64         let bindings: Set<PushAddressBinding>
     65         let mutedKinds: Set<String>
     66     }
     67 
     68     /// `nil` when the worker isn't configured (e.g. a fresh checkout without a
     69     /// `Local.xcconfig`) or when the bundle's APNs environment is missing or
     70     /// unrecognised. The rest of the app treats a nil PushClient as "push
     71     /// notifications are disabled" rather than crashing.
     72     /// Dedicated session for worker traffic. `waitsForConnectivity` lets a send
     73     /// issued right at app-foreground — before the radio/path is back up — wait
     74     /// for connectivity instead of failing immediately (a common source of the
     75     /// `-1005`/`-1009` registration failures), bounded by the resource timeout.
     76     /// It governs connection establishment, not a mid-transfer drop, so it pairs
     77     /// with the transport retry in `sendAuthorized` rather than replacing it.
     78     /// A dedicated session also keeps the worker's connection pool off `.shared`,
     79     /// so unrelated traffic doesn't churn the pooled connections it reuses.
     80     static func makeWorkerSession() -> URLSession {
     81         let config = URLSessionConfiguration.default
     82         config.waitsForConnectivity = true
     83         config.timeoutIntervalForRequest = 30
     84         config.timeoutIntervalForResource = 60
     85         return URLSession(configuration: config)
     86     }
     87 
     88     init?(
     89         deviceID: String = RecordSerializer.localDeviceID,
     90         session: URLSession = PushClient.makeWorkerSession(),
     91         log: @escaping (String) -> Void = { _ in }
     92     ) {
     93         guard
     94             let rawBase = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBaseURL") as? String,
     95             case let trimmedBase = rawBase.trimmingCharacters(in: .whitespacesAndNewlines),
     96             !trimmedBase.isEmpty,
     97             let base = URL(string: trimmedBase)
     98         else { return nil }
     99         self.baseURL = base
    100         self.deviceID = deviceID
    101         self.session = session
    102         self.log = log
    103         self.authenticator = PushRequestAuthenticator(
    104             baseURL: base,
    105             deviceID: deviceID,
    106             session: session
    107         )
    108         // CrossmateAPSEnvironment carries the same APS_ENVIRONMENT build
    109         // setting that fills the aps-environment entitlement, so the
    110         // environment registered with the worker always matches the
    111         // environment the APNs token was issued for.
    112         switch Bundle.main.object(forInfoDictionaryKey: "CrossmateAPSEnvironment") as? String {
    113         case "development":
    114             self.environment = .sandbox
    115         case "production":
    116             self.environment = .production
    117         case let other:
    118             log("Push disabled: unrecognised APNs environment \(other ?? "<missing>")")
    119             return nil
    120         }
    121     }
    122 
    123     func updateAPNsToken(_ data: Data) {
    124         let hex = data.map { String(format: "%02x", $0) }.joined()
    125         if hex == apnsToken { return }
    126         apnsToken = hex
    127         Task { await reconcile() }
    128     }
    129 
    130     /// Records the current account identity. Used only as the `fromAuthorID`
    131     /// display field on outgoing pushes — the worker no longer keys anything
    132     /// on identity, so an account switch is reflected purely through the
    133     /// address set changing (see `setAddresses`).
    134     func updateAuthorID(_ newAuthorID: String?) {
    135         let normalized = newAuthorID?.trimmingCharacters(in: .whitespaces)
    136         authorID = (normalized?.isEmpty == false) ? normalized : nil
    137     }
    138 
    139     /// Sets the full set of address→credential bindings this device should be
    140     /// registered under. The caller (AccountPushCoordinator) recomputes this
    141     /// from Core Data on launch, when a shared game appears, and on account
    142     /// switch. Bindings that drop out are unregistered so a left/old-account
    143     /// game stops delivering here.
    144     func setAddresses(_ next: Set<PushAddressBinding>) {
    145         if next == bindings { return }
    146         bindings = next
    147         Task { await reconcile() }
    148     }
    149 
    150     /// Sets the push kinds this device's notification settings have muted.
    151     /// The caller (AccountPushCoordinator) mirrors this from preferences on
    152     /// launch and on every settings change.
    153     func setMutedKinds(_ next: Set<String>) {
    154         if next == mutedKinds { return }
    155         mutedKinds = next
    156         Task { await reconcile() }
    157     }
    158 
    159     private func reconcile() async {
    160         guard let apnsToken else { return }
    161         let desired = bindings
    162         let signature = Registration(
    163             token: apnsToken,
    164             bindings: desired,
    165             mutedKinds: mutedKinds
    166         )
    167         if lastRegistered == signature { return }
    168         let toRemove = (lastRegistered?.bindings ?? []).subtracting(desired)
    169         do {
    170             // Register each game's shared credential first, so the worker
    171             // accepts the addresses bound to it.
    172             for creds in Set(desired.compactMap(\.credentials)) {
    173                 await registerGameCredential(creds)
    174             }
    175             if !desired.isEmpty {
    176                 try await register(
    177                     token: apnsToken,
    178                     bindings: Array(desired),
    179                     mutedKinds: signature.mutedKinds.sorted()
    180                 )
    181             }
    182             if !toRemove.isEmpty {
    183                 await unregister(bindings: Array(toRemove))
    184             }
    185             lastRegistered = signature
    186         } catch {
    187             // Worker is idempotent; the next token delivery or address change
    188             // will retry. Bubble the failure into diagnostics rather than
    189             // surfacing a user-facing error.
    190             log("Push register failed: \(error.localizedDescription)")
    191         }
    192     }
    193 
    194     /// One push addressee, identified by the recipient's per-(account, game)
    195     /// `pushAddress` capability rather than an identity. `body`, if set,
    196     /// overrides the top-level broadcast `body` for this recipient only — the
    197     /// receiver-side notification text can then be personalised (e.g. the
    198     /// pause-summary counts that depend on when that specific peer last read
    199     /// the puzzle).
    200     struct Addressee: Sendable, Equatable {
    201         let address: String
    202         let body: String?
    203         /// Structured semantics for this recipient, encoded into the wire
    204         /// `payload` field. The worker forwards it opaquely; the notification
    205         /// service extension decodes it (e.g. to decide the badge).
    206         let payload: PushPayload?
    207 
    208         init(address: String, body: String? = nil, payload: PushPayload? = nil) {
    209             self.address = address
    210             self.body = body
    211             self.payload = payload
    212         }
    213     }
    214 
    215     /// Fire-and-forget publish. The worker maps each addressee's `address` to
    216     /// that account's registered device tokens and fans the push out to all
    217     /// of them. Failures are logged but never surfaced — pushes are advisory
    218     /// and the next event will retry the underlying state on its own.
    219     /// When `broadcast` is true the worker fans the push out to every device
    220     /// registered under the game's credential — the whole room — instead of an
    221     /// explicit `addressees` list, so the sender needn't know who the
    222     /// participants are (their Player records may not have synced yet). The
    223     /// uniform `broadcastPayload` and `body` are delivered to all of them, and
    224     /// `excludeAddress` (the sender's own derived game address) keeps the
    225     /// sender's other devices from being notified. Broadcast is only meaningful
    226     /// for a game-credentialed push.
    227     /// Stable `apns-collapse-id` for a game's notification tile. All alert
    228     /// pushes for one game share it, so APNs keeps a single replacing tile in
    229     /// Notification Center (the receiver's NSE then folds successive `pause`
    230     /// summaries into it). 41 chars — well under APNs' 64-byte limit.
    231     static func gameCollapseID(_ gameID: UUID) -> String {
    232         "game-\(gameID.uuidString)"
    233     }
    234 
    235     func publish(
    236         kind: String,
    237         gameID: UUID,
    238         addressees: [Addressee],
    239         title: String,
    240         puzzleTitle: String? = nil,
    241         background: Bool = false,
    242         gameCredentialed: Bool = true,
    243         broadcast: Bool = false,
    244         excludeAddress: String? = nil,
    245         broadcastPayload: PushPayload? = nil,
    246         collapseID: String? = nil,
    247         extra: [String: Any] = [:],
    248         body: String
    249     ) async {
    250         guard broadcast || !addressees.isEmpty else { return }
    251         // A game push must prove participation: resolve (minting if needed) the
    252         // game's shared credential, register it with the worker, and sign the
    253         // request below. Account-scoped publishes (sibling-device hints) carry
    254         // no game and stay on the App-Attest-only path.
    255         var credential: GamePushCredentials?
    256         if gameCredentialed {
    257             guard let creds = gameCredentialResolver?(gameID) else {
    258                 log("push(\(kind)): skipped (no game credential)")
    259                 return
    260             }
    261             await registerGameCredential(creds)
    262             credential = creds
    263         }
    264         log(broadcast
    265             ? "push(\(kind)): broadcasting to room"
    266             : "push(\(kind)): publishing to \(addressees.count) addressee(s)")
    267         // Stamp the puzzle title onto each addressee's structured payload so the
    268         // receiver's extension can recompose the body from components (swapping
    269         // in its private nickname) rather than editing the sender's text.
    270         // Injected here so the call sites pass it once, not per addressee.
    271         // Resolve (and mint) the game's content key only when there is a payload
    272         // to seal, so account-scoped pushes that carry none don't mint one.
    273         let needsContentKey = broadcastPayload != nil || addressees.contains { $0.payload != nil }
    274         let contentKey = needsContentKey ? contentKeyResolver?(gameID) : nil
    275         // Encrypt each addressee's structured payload under the content key and
    276         // ship it as `enc`. The per-recipient cleartext `body` (personalised
    277         // pause counts) is deliberately *not* sent: those counts live in the
    278         // sealed payload's event, and the receiver's NSE recomposes the body —
    279         // so the worker never sees the wording. An addressee whose payload can't
    280         // be sealed (no key resolved) still gets the push, with the generic body.
    281         let addresseePayloads: [[String: Any]] = addressees.map { addressee in
    282             var entry: [String: Any] = ["address": addressee.address]
    283             if var payload = addressee.payload {
    284                 if let puzzleTitle { payload.puzzleTitle = puzzleTitle }
    285                 if let contentKey, let sealed = PushPayloadCipher.seal(payload, key: contentKey) {
    286                     entry["enc"] = sealed
    287                 }
    288             }
    289             return entry
    290         }
    291         var payload: [String: Any] = [
    292             "kind": kind,
    293             "gameID": gameID.uuidString,
    294             "fromAuthorID": authorID ?? "",
    295             "senderDeviceID": deviceID,
    296             "title": title,
    297             // Generic wording only; the real body is sealed into the payload and
    298             // recomposed on-device. A bodyless (background) push stays bodyless.
    299             "alertBody": body.isEmpty ? "" : Self.genericAlertBody,
    300             "addressees": addresseePayloads,
    301             "background": background
    302         ]
    303         // The credID names which registered credential the worker verifies the
    304         // game signature against and scopes delivery to. Covered by the body
    305         // hash (App Attest), so it can't be swapped without breaking auth.
    306         if let credential {
    307             payload["credID"] = credential.credID.uuidString
    308         }
    309         // Forwarded verbatim into the APNs `apns-collapse-id` header by the
    310         // worker (alert pushes only). Opaque to the worker — the coalescing
    311         // policy it encodes lives here, not in the worker.
    312         if let collapseID {
    313             payload["collapseID"] = collapseID
    314         }
    315         // Broadcast: the worker resolves targets from the credential's whole
    316         // address set, so the empty `addressees` above is ignored. Carry the
    317         // uniform payload at top level (the per-addressee slot is unused) and
    318         // name the sender's own address so its other devices are skipped.
    319         if broadcast {
    320             payload["broadcast"] = true
    321             if let excludeAddress { payload["excludeAddress"] = excludeAddress }
    322             if var broadcastPayload {
    323                 if let puzzleTitle { broadcastPayload.puzzleTitle = puzzleTitle }
    324                 if let contentKey, let sealed = PushPayloadCipher.seal(broadcastPayload, key: contentKey) {
    325                     payload["enc"] = sealed
    326                 }
    327             }
    328         }
    329         for (key, value) in extra {
    330             payload[key] = value
    331         }
    332         var request = URLRequest(url: baseURL.appendingPathComponent("publish"))
    333         request.httpMethod = "POST"
    334         do {
    335             let body = try JSONSerialization.data(withJSONObject: payload)
    336             request.httpBody = body
    337             let (data, response) = try await sendAuthorized(
    338                 request,
    339                 method: "POST",
    340                 path: "/publish",
    341                 body: body,
    342                 gameCredential: credential
    343             )
    344             guard response.statusCode == 200 else {
    345                 throw URLError(.badServerResponse)
    346             }
    347             log("push(\(kind)): worker accepted\(Self.deliverySummary(from: data))")
    348         } catch {
    349             log("push(\(kind)) failed: \(error.localizedDescription)")
    350         }
    351     }
    352 
    353     func publishAccountEvent(
    354         kind: String,
    355         gameID: UUID,
    356         address: String,
    357         readAt: Date? = nil
    358     ) async {
    359         var extra: [String: Any] = [:]
    360         if let readAt {
    361             extra["readAt"] = Self.iso8601.string(from: readAt)
    362         }
    363         await publish(
    364             kind: kind,
    365             gameID: gameID,
    366             addressees: [Addressee(address: address)],
    367             title: "",
    368             background: true,
    369             gameCredentialed: false,
    370             extra: extra,
    371             body: ""
    372         )
    373     }
    374 
    375     /// Formats the worker's publish response counts for the diagnostics log,
    376     /// e.g. " (delivered=2 muted=1 removed=0 failed=0)". Production delivery
    377     /// is only observable through the on-device log, so this is where "why
    378     /// didn't that push arrive?" gets answered — `muted` in particular means
    379     /// a recipient device turned that notification kind off in settings.
    380     /// Returns "" for an unparseable body (e.g. a worker predating a count).
    381     nonisolated static func deliverySummary(from data: Data) -> String {
    382         guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
    383             return ""
    384         }
    385         let counts = ["delivered", "muted", "removed", "failed"].compactMap { key in
    386             (json[key] as? Int).map { "\(key)=\($0)" }
    387         }
    388         return counts.isEmpty ? "" : " (\(counts.joined(separator: " ")))"
    389     }
    390 
    391     private static let iso8601: ISO8601DateFormatter = {
    392         let formatter = ISO8601DateFormatter()
    393         formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    394         formatter.timeZone = TimeZone(secondsFromGMT: 0)
    395         return formatter
    396     }()
    397 
    398     /// Encodes the bindings as the worker's `[{address, credID?}]` wire shape.
    399     /// A nil credential (the account-scoped address) omits `credID`, so the
    400     /// worker stores it on the legacy address-only key.
    401     private func addressPayload(for bindings: [PushAddressBinding]) -> [[String: Any]] {
    402         bindings.map { binding in
    403             var entry: [String: Any] = ["address": binding.address]
    404             if let credID = binding.credentials?.credID {
    405                 entry["credID"] = credID.uuidString
    406             }
    407             return entry
    408         }
    409     }
    410 
    411     private func register(
    412         token: String,
    413         bindings: [PushAddressBinding],
    414         mutedKinds: [String]
    415     ) async throws {
    416         var request = URLRequest(url: baseURL.appendingPathComponent("register"))
    417         request.httpMethod = "POST"
    418         let body: [String: Any] = [
    419             "deviceID": deviceID,
    420             "token": token,
    421             "environment": environment.rawValue,
    422             "addresses": addressPayload(for: bindings),
    423             "mutedKinds": mutedKinds
    424         ]
    425         let data = try JSONSerialization.data(withJSONObject: body)
    426         request.httpBody = data
    427         let (_, response) = try await sendAuthorized(
    428             request,
    429             method: "POST",
    430             path: "/register",
    431             body: data
    432         )
    433         try assert204(response)
    434     }
    435 
    436     private func unregister(bindings: [PushAddressBinding]) async {
    437         var request = URLRequest(url: baseURL.appendingPathComponent("register"))
    438         request.httpMethod = "DELETE"
    439         let data = (try? JSONSerialization.data(withJSONObject: [
    440             "deviceID": deviceID,
    441             "addresses": addressPayload(for: bindings)
    442         ])) ?? Data()
    443         request.httpBody = data
    444         do {
    445             let (_, response) = try await sendAuthorized(
    446                 request,
    447                 method: "DELETE",
    448                 path: "/register",
    449                 body: data
    450             )
    451             try assert204(response)
    452         } catch {
    453             log("Push unregister failed: \(error.localizedDescription)")
    454         }
    455     }
    456 
    457     /// Registers a game's shared push credential with the worker (first-write-
    458     /// wins) so it will verify publishes signed with that secret. Idempotent
    459     /// and deduped per session; mirrors `EngagementHost.registerRoom`. A 409
    460     /// means a different secret is already registered under this credID — only
    461     /// possible on an (astronomically unlikely) credID collision, so it is
    462     /// logged rather than retried.
    463     private func registerGameCredential(_ credentials: GamePushCredentials) async {
    464         if registeredGameCredentials[credentials.credID] == credentials.secret { return }
    465         let path = "/games/\(credentials.credID.uuidString)/register"
    466         var request = URLRequest(
    467             url: baseURL
    468                 .appendingPathComponent("games")
    469                 .appendingPathComponent(credentials.credID.uuidString)
    470                 .appendingPathComponent("register")
    471         )
    472         request.httpMethod = "POST"
    473         let data = (try? JSONSerialization.data(withJSONObject: ["secret": credentials.secret])) ?? Data()
    474         request.httpBody = data
    475         do {
    476             let (_, response) = try await sendAuthorized(
    477                 request,
    478                 method: "POST",
    479                 path: path,
    480                 body: data
    481             )
    482             switch response.statusCode {
    483             case 200..<300:
    484                 registeredGameCredentials[credentials.credID] = credentials.secret
    485             case 409:
    486                 log("Push game-credential rejected (secret mismatch) for \(credentials.credID)")
    487             default:
    488                 log("Push game-credential register failed: HTTP \(response.statusCode)")
    489             }
    490         } catch {
    491             log("Push game-credential register failed: \(error.localizedDescription)")
    492         }
    493     }
    494 
    495     private func sendAuthorized(
    496         _ request: URLRequest,
    497         method: String,
    498         path: String,
    499         body: Data,
    500         gameCredential: GamePushCredentials? = nil
    501     ) async throws -> (Data, HTTPURLResponse) {
    502         // Transport-level retry. `-1005 networkConnectionLost` / `-1001 timedOut`
    503         // against the Cloudflare worker are usually a keep-alive connection-reuse
    504         // race — the edge closed a pooled connection the device then reused —
    505         // rather than a real outage (CloudKit keeps syncing through them). They're
    506         // retryable: a fresh attempt opens a new connection. Each attempt re-signs
    507         // via the full overload below — `signedHeaders` mints a fresh nonce and a
    508         // new App Attest assertion whose counter is monotonic — so a retry can't
    509         // replay a stale assertion and regress the worker's stored counter. Every
    510         // worker write reached through here is idempotent, so re-sending is safe.
    511         // Bounded: a genuinely offline device exhausts the attempts and the next
    512         // reconcile trigger re-registers.
    513         let maxAttempts = 3
    514         var attempt = 0
    515         while true {
    516             do {
    517                 return try await sendAuthorized(
    518                     request,
    519                     method: method,
    520                     path: path,
    521                     body: body,
    522                     gameCredential: gameCredential,
    523                     retryAfterRegistrationReset: true
    524                 )
    525             } catch let error as URLError
    526                 where Self.isRetryableTransport(error.code) && attempt + 1 < maxAttempts {
    527                 attempt += 1
    528                 // Short backoff (200ms, 400ms) so an instantaneous reuse race
    529                 // isn't hammered and a momentarily-busy edge gets a beat.
    530                 try? await Task.sleep(for: .milliseconds(200 * attempt))
    531             }
    532         }
    533     }
    534 
    535     /// Transport failures worth retrying for an idempotent worker write.
    536     /// Deliberately excludes `.notConnectedToInternet`/`.cancelled` — a truly
    537     /// offline or cancelled send should fail fast and let the next reconcile
    538     /// re-register rather than spin through the backoff.
    539     private static func isRetryableTransport(_ code: URLError.Code) -> Bool {
    540         switch code {
    541         case .networkConnectionLost, .timedOut, .cannotConnectToHost,
    542              .cannotFindHost, .dnsLookupFailed, .secureConnectionFailed:
    543             return true
    544         default:
    545             return false
    546         }
    547     }
    548 
    549     private func sendAuthorized(
    550         _ request: URLRequest,
    551         method: String,
    552         path: String,
    553         body: Data,
    554         gameCredential: GamePushCredentials?,
    555         retryAfterRegistrationReset: Bool
    556     ) async throws -> (Data, HTTPURLResponse) {
    557         var request = request
    558         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    559         let headers = try await authenticator.signedHeaders(
    560             method: method,
    561             path: path,
    562             body: body
    563         )
    564         for (key, value) in headers {
    565             request.setValue(value, forHTTPHeaderField: key)
    566         }
    567         // Prove game participation by signing the App Attest request's own
    568         // body hash / timestamp / nonce with the game secret. The worker
    569         // re-derives this from the secret it holds for the body's credID.
    570         if let gameCredential,
    571            let bodyHash = headers["X-Crossmate-Body-SHA256"],
    572            let timestamp = headers["X-Crossmate-Timestamp"],
    573            let nonce = headers["X-Crossmate-Nonce"] {
    574             let payload = GamePushSigner.signaturePayload(
    575                 credID: gameCredential.credID,
    576                 bodyHash: bodyHash,
    577                 timestamp: timestamp,
    578                 nonce: nonce
    579             )
    580             if let signature = try? GamePushSigner.signature(
    581                 payload: payload,
    582                 secret: gameCredential.secret
    583             ) {
    584                 request.setValue(signature, forHTTPHeaderField: "X-Crossmate-Game-Signature")
    585             }
    586         }
    587         let (data, response) = try await session.data(for: request)
    588         guard let http = response as? HTTPURLResponse else {
    589             throw URLError(.badServerResponse)
    590         }
    591         if http.statusCode == 401, retryAfterRegistrationReset {
    592             await authenticator.resetRegistration()
    593             return try await sendAuthorized(
    594                 request,
    595                 method: method,
    596                 path: path,
    597                 body: body,
    598                 gameCredential: gameCredential,
    599                 retryAfterRegistrationReset: false
    600             )
    601         }
    602         return (data, http)
    603     }
    604 
    605     private func assert204(_ response: HTTPURLResponse) throws {
    606         guard response.statusCode == 204 else {
    607             throw URLError(.badServerResponse)
    608         }
    609     }
    610 }