crossmate

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

PushClient.swift (30229B)


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