commit 260636be81914633d537b6115993377b812c79ee
parent b2932836af34edbd37ed38fb7f22237df7fe8a4b
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 10:24:45 +0900
Address push notifications by capability token
Prior to this commit, the push worker authenticated every request with a
single bearer baked into the app binary, and addressed pushes by
authorID — a value that is both forgeable in the payload and known to
every co-participant in a shared game (it rides the Player records). So
the worker was implicitly being asked 'is this sender allowed to notify
this authorID?', a question it had no way to answer, and just trusted
the caller. Anyone who extracted the bearer could push arbitrary banner
text to any user whose authorID they knew.
This commit replaces identity addressing with an unguessable capability
token. Each shared game carries a per-(account, game) pushAddress — a
256-bit URL-safe token — on the local author's Player record. Only share
participants can read that record, so possession of the token, not an
identity, is the authorisation, and CloudKit's share ACL is the
gatekeeper. The worker stops knowing about identity entirely and becomes
a pure relay keyed addr:<token>:<deviceID> → APNs token; fromAuthorID
survives only as cosmetic display text and the bearer drops to a coarse
rate-limit gate. A binary-extraction attacker who knows an authorID can
no longer reach anyone.
The token flows through the existing Player record: RecordSerializer
writes/parses pushAddress, RecordBuilder carries it outbound, and
RecordApplier adopts it inbound — which doubles as the cross-device
convergence channel, since an account's devices settle on one per-game
address through that record's last-writer-wins. GameStore.setPushAddress
mints one slot; reconcileLocalPushAddresses mints across every shared
game in a single save and reports the freshly-minted games to republish.
Registration moves to PushClient reconciling a (token, address-set)
pair: a token rotation re-binds every address, an address-set change
registers the delta and unregisters anything that dropped out (a left or
old-account game). AppServices.reconcilePushRegistration recomputes the
set on launch, when a shared game first appears, on account switch, and
on open — so a push reaches all of the account's devices rather than
only the one a game was opened on, and survives an APNs token rotation.
Recipients with no published address yet are skipped, so the sender
degrades gracefully during rollout.
The worker's register/unregister batch by address and publish fans an
address out to its registered devices; dead tokens are dropped on APNs
410 as before. Existing shared games self-migrate on the next launch's
reconcile, which mints and republishes their Player records.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
12 files changed, 429 insertions(+), 80 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -27,6 +27,7 @@
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; };
2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
+ 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; };
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; };
@@ -247,6 +248,7 @@
9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; };
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; };
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
+ 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; };
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; };
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; };
@@ -354,6 +356,7 @@
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
+ 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
@@ -684,6 +687,7 @@
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
+ 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -698,6 +698,10 @@ private struct PuzzleDisplayView: View {
// each fires its own drain — same shape as Moves.
let syncEngine = services.syncEngine
let burstScope = await syncEngine.beginPlayerSendBurst(gameID: gameID)
+ // Mint this game's push address (if absent) inside the burst so it
+ // ships on the same Player-record write as the read-cursor lease;
+ // registration of this device under it happens just after the burst.
+ _ = services.store.setPushAddress(gameID: gameID, authorID: authorID)
await services.publishReadCursor(for: gameID, mode: .activeLease)
await services.playerNamePublisher?.publishName(for: gameID)
await selectionPublisher.begin(
@@ -712,6 +716,9 @@ private struct PuzzleDisplayView: View {
if let burstScope {
await syncEngine.endPlayerSendBurst(scope: burstScope)
}
+ // Register this device under the (now-minted) address set so the
+ // just-opened game can deliver pushes here immediately.
+ await services.reconcilePushRegistration()
await services.startEngagementIfPossible(gameID: gameID)
let services = self.services
let eventGameID = gameID
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -37,6 +37,7 @@
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
<attribute name="lastMovesSnapshotData" optional="YES" attributeType="Binary"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
+ <attribute name="pushAddress" optional="YES" attributeType="String"/>
<attribute name="readAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -2,6 +2,7 @@ import CloudKit
import CoreData
import Foundation
import Observation
+import Security
/// Per-cell state for rendering a thumbnail. Plain value type so
/// SwiftUI can diff it cheaply.
@@ -932,6 +933,105 @@ final class GameStore {
saveContext("setLastMovesSnapshot")
}
+ /// Ensures the local author's Player record for `gameID` carries a push
+ /// address, minting a random high-entropy token if none is set yet.
+ /// Returns the current address (existing or freshly minted), or `nil` if
+ /// the GameEntity is missing. The token is the per-(account, game)
+ /// capability a co-participant reads off the shared Player record to push
+ /// to this account for this game; it syncs across the account's own
+ /// devices through that same record under last-writer-wins.
+ @discardableResult
+ func setPushAddress(gameID: UUID, authorID: String) -> String? {
+ let entity: PlayerEntity
+ if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
+ entity = existing
+ } else {
+ let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameRequest.fetchLimit = 1
+ guard let game = try? context.fetch(gameRequest).first else { return nil }
+ entity = PlayerEntity(context: context)
+ entity.game = game
+ entity.authorID = authorID
+ entity.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: authorID
+ )
+ entity.updatedAt = Date()
+ }
+ if let existing = entity.pushAddress, !existing.isEmpty {
+ return existing
+ }
+ let address = Self.makePushAddress()
+ entity.pushAddress = address
+ // Bump updatedAt so the freshly stamped address wins LWW against a
+ // sibling that has not minted one, and so the outbound build picks it
+ // up as a fresh write.
+ entity.updatedAt = Date()
+ saveContext("setPushAddress")
+ return address
+ }
+
+ /// A 256-bit, URL-safe random capability token. Opaque and unguessable —
+ /// possession is the sole authorisation to push to the slot it addresses.
+ private static func makePushAddress() -> String {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ /// Mints (if needed) the local author's push address for every shared
+ /// game the account participates in, and returns the full set of those
+ /// addresses together with the games whose address was newly minted. The
+ /// caller registers the set with the push worker and republishes the
+ /// minted games' Player records so peers learn where to address pushes.
+ /// One Core Data save covers all mints.
+ func reconcileLocalPushAddresses(
+ authorID: String
+ ) -> (addresses: Set<String>, mintedGameIDs: [UUID]) {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(
+ format: "databaseScope == 1 OR ckShareRecordName != nil"
+ )
+ let games = (try? context.fetch(request)) ?? []
+ var addresses: Set<String> = []
+ var mintedGameIDs: [UUID] = []
+ var didMint = false
+ for game in games {
+ guard let gameID = game.id else { continue }
+ let player: PlayerEntity
+ if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
+ player = existing
+ } else {
+ player = PlayerEntity(context: context)
+ player.game = game
+ player.authorID = authorID
+ player.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: authorID
+ )
+ player.updatedAt = Date()
+ }
+ if let address = player.pushAddress, !address.isEmpty {
+ addresses.insert(address)
+ } else {
+ let address = Self.makePushAddress()
+ player.pushAddress = address
+ player.updatedAt = Date()
+ addresses.insert(address)
+ mintedGameIDs.append(gameID)
+ didMint = true
+ }
+ }
+ if didMint {
+ saveContext("reconcileLocalPushAddresses")
+ }
+ return (addresses, mintedGameIDs)
+ }
+
/// Player record `updatedAt` for `(gameID, authorID)` — `nil` if no row
/// exists yet. Used as the peer-device liveness probe during the pause
/// grace window: a value newer than `pauseStart` means a sibling device
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -355,6 +355,10 @@ final class AppServices {
// not on moves and not on their later name / cursor updates.
await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
await self?.reconcileFriendships(forGameIDs: gameIDs)
+ // A newly-arrived shared game means a new address slot to mint and
+ // a token to register under it (so this device can receive the
+ // game's pushes without opening it first).
+ await self?.reconcilePushRegistration()
}
// An inbound Player record may have updated a peer's cursor track;
@@ -398,6 +402,9 @@ final class AppServices {
guard let self else { return }
await self.identity.refresh(using: self.ckContainer)
self.pushClient?.updateAuthorID(self.identity.currentID)
+ // Recompute the address set for the new account; addresses that
+ // belonged to the old account drop out and are unregistered.
+ await self.reconcilePushRegistration()
}
await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements] gameID in
@@ -603,6 +610,28 @@ final class AppServices {
await publishCompletionPush(gameID: gameID, resigned: resigned)
}
+ /// Ensures this device is registered with the push worker under the
+ /// account's per-game push address for every shared game, minting and
+ /// publishing any address that doesn't exist yet so peers learn where to
+ /// reach this account. Deduped inside `PushClient`, so it's cheap to call
+ /// repeatedly — on launch (sync ready), when a shared game appears
+ /// (inbound Player records), and on account switch. This is what makes a
+ /// push reach *all* of the account's devices, not just the one a game was
+ /// opened on, and survive an APNs token rotation.
+ func reconcilePushRegistration() async {
+ guard let pushClient, preferences.isICloudSyncEnabled else { return }
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let result = store.reconcileLocalPushAddresses(authorID: authorID)
+ for gameID in result.mintedGameIDs {
+ await syncEngine.enqueuePlayer(
+ gameID: gameID,
+ authorID: authorID,
+ reason: "pushAddress"
+ )
+ }
+ pushClient.setAddresses(result.addresses)
+ }
+
/// Sender-side session-begin push. Replaces the receiver-side
/// `SessionMonitor.presentBegins(...)` path: the opening device owns the
/// notification timing, so peers get "Alice is solving X" the instant
@@ -635,12 +664,19 @@ final class AppServices {
syncMonitor.note("push(play): skipped (access revoked)")
return
}
+ let addressees = plan.recipients.compactMap { recipient in
+ recipient.pushAddress.map { PushClient.Addressee(address: $0) }
+ }
+ guard !addressees.isEmpty else {
+ syncMonitor.note("push(play): skipped (no addressable recipients)")
+ return
+ }
let playerName = preferences.name.isEmpty ? "A player" : preferences.name
let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'"
await pushClient.publish(
kind: "play",
gameID: gameID,
- addressees: plan.recipients.map { PushClient.Addressee(authorID: $0.authorID) },
+ addressees: addressees,
title: "Crossmate",
body: "\(playerName) is solving \(puzzleSuffix)"
)
@@ -730,6 +766,8 @@ final class AppServices {
let mergedCells = store.mergedAuthorCells(for: gameID, by: localAuthorID)
var addressees: [PushClient.Addressee] = []
for recipient in plan.recipients {
+ // No capability published yet → unaddressable, drop it.
+ guard let address = recipient.pushAddress else { continue }
let readAt = recipient.readAt ?? .distantPast
var added = 0
var cleared = 0
@@ -743,7 +781,7 @@ final class AppServices {
added: added,
cleared: cleared
)
- addressees.append(PushClient.Addressee(authorID: recipient.authorID, body: body))
+ addressees.append(PushClient.Addressee(address: address, body: body))
}
guard !addressees.isEmpty else {
syncMonitor.note("push(pause): skipped (all recipients up to date)")
@@ -782,6 +820,13 @@ final class AppServices {
syncMonitor.note("push(\(kindLabel)): skipped (no recipients)")
return
}
+ let addressees = plan.recipients.compactMap { recipient in
+ recipient.pushAddress.map { PushClient.Addressee(address: $0) }
+ }
+ guard !addressees.isEmpty else {
+ syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)")
+ return
+ }
let playerName = preferences.name.isEmpty ? "A player" : preferences.name
let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'"
let kind: String
@@ -796,7 +841,7 @@ final class AppServices {
await pushClient.publish(
kind: kind,
gameID: gameID,
- addressees: plan.recipients.map { PushClient.Addressee(authorID: $0.authorID) },
+ addressees: addressees,
title: "Crossmate",
body: body
)
@@ -810,6 +855,11 @@ final class AppServices {
struct PushRecipient: Sendable, Equatable {
let authorID: String
let readAt: Date?
+ /// The recipient's per-(account, game) push capability, read off their
+ /// Player record. `nil` when they haven't published one yet (older
+ /// build, or not-yet-synced) — such a recipient can't be addressed and
+ /// is dropped from the push.
+ let pushAddress: String?
}
private struct PushPlan {
@@ -836,7 +886,7 @@ final class AppServices {
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
gReq.fetchLimit = 1
guard let game = try? ctx.fetch(gReq).first else { return .empty }
- var byAuthor: [String: Date?] = [:]
+ var byAuthor: [String: (Date?, String?)] = [:]
let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
pReq.predicate = NSPredicate(format: "game == %@", game)
for p in (try? ctx.fetch(pReq)) ?? [] {
@@ -845,9 +895,11 @@ final class AppServices {
a != CKCurrentUserDefaultName,
!a.isEmpty
else { continue }
- byAuthor[a] = p.readAt
+ byAuthor[a] = (p.readAt, p.pushAddress)
+ }
+ let recipients = byAuthor.map {
+ PushRecipient(authorID: $0.key, readAt: $0.value.0, pushAddress: $0.value.1)
}
- let recipients = byAuthor.map { PushRecipient(authorID: $0.key, readAt: $0.value) }
return PushPlan(
recipients: recipients,
title: PuzzleNotificationText.title(for: game),
@@ -1577,6 +1629,7 @@ final class AppServices {
}
isReadyForShareAcceptance = true
await processPendingShareAcceptances()
+ await reconcilePushRegistration()
return true
}
syncStartTask = task
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -21,12 +21,19 @@ final class PushClient {
private var apnsToken: String?
private var authorID: String?
+ /// The per-(account, game) addresses this device should be reachable at —
+ /// one per shared game the account participates in. The worker maps each
+ /// to this device's APNs token so a push to the address reaches here.
+ private var addresses: Set<String> = []
private var lastRegistered: Registration?
+ /// The `(token, addresses)` pair last successfully reconciled with the
+ /// worker. Both must match for a reconcile to be a no-op, so a token
+ /// rotation re-binds every address and an address-set change registers
+ /// the delta.
private struct Registration: Equatable {
- let authorID: String
- let deviceID: String
let token: String
+ let addresses: Set<String>
}
/// `nil` when the worker isn't configured (e.g. a fresh checkout without a
@@ -64,53 +71,68 @@ final class PushClient {
Task { await reconcile() }
}
+ /// Records the current account identity. Used only as the `fromAuthorID`
+ /// display field on outgoing pushes — the worker no longer keys anything
+ /// on identity, so an account switch is reflected purely through the
+ /// address set changing (see `setAddresses`).
func updateAuthorID(_ newAuthorID: String?) {
let normalized = newAuthorID?.trimmingCharacters(in: .whitespaces)
- let next = (normalized?.isEmpty == false) ? normalized : nil
- if next == authorID { return }
- // Account switch: drop the previous (authorID, deviceID) so the worker
- // stops pushing to the wrong identity on this device.
- if let previous = authorID, previous != next {
- Task { await unregister(authorID: previous) }
- lastRegistered = nil
- }
- authorID = next
+ authorID = (normalized?.isEmpty == false) ? normalized : nil
+ }
+
+ /// Sets the full set of addresses this device should be registered under.
+ /// The caller (AppServices) recomputes this from Core Data on launch, when
+ /// a shared game appears, and on account switch. Addresses that drop out
+ /// of the set are unregistered so a left/old-account game stops delivering
+ /// here.
+ func setAddresses(_ next: Set<String>) {
+ if next == addresses { return }
+ addresses = next
Task { await reconcile() }
}
private func reconcile() async {
- guard let authorID, let apnsToken else { return }
- let triple = Registration(authorID: authorID, deviceID: deviceID, token: apnsToken)
- if lastRegistered == triple { return }
+ guard let apnsToken else { return }
+ let desired = addresses
+ let signature = Registration(token: apnsToken, addresses: desired)
+ if lastRegistered == signature { return }
+ let toRemove = (lastRegistered?.addresses ?? []).subtracting(desired)
do {
- try await register(triple)
- lastRegistered = triple
+ if !desired.isEmpty {
+ try await register(token: apnsToken, addresses: Array(desired))
+ }
+ if !toRemove.isEmpty {
+ await unregister(addresses: Array(toRemove))
+ }
+ lastRegistered = signature
} catch {
- // Worker is idempotent; the next token delivery or authorID change
+ // Worker is idempotent; the next token delivery or address change
// will retry. Bubble the failure into diagnostics rather than
// surfacing a user-facing error.
log("Push register failed: \(error.localizedDescription)")
}
}
- /// One push addressee. `body`, if set, overrides the top-level broadcast
- /// `body` for this recipient only — the receiver-side notification text
- /// can then be personalised (e.g. the pause-summary counts that depend on
- /// when that specific peer last read the puzzle).
+ /// One push addressee, identified by the recipient's per-(account, game)
+ /// `pushAddress` capability rather than an identity. `body`, if set,
+ /// overrides the top-level broadcast `body` for this recipient only — the
+ /// receiver-side notification text can then be personalised (e.g. the
+ /// pause-summary counts that depend on when that specific peer last read
+ /// the puzzle).
struct Addressee: Sendable, Equatable {
- let authorID: String
+ let address: String
let body: String?
- init(authorID: String, body: String? = nil) {
- self.authorID = authorID
+ init(address: String, body: String? = nil) {
+ self.address = address
self.body = body
}
}
- /// Fire-and-forget publish. The worker fans the push out to every
- /// addressee author's registered devices. Failures are logged but never
- /// surfaced — pushes are advisory and the next event will retry the
- /// underlying state on its own.
+ /// Fire-and-forget publish. The worker maps each addressee's `address` to
+ /// that account's registered device tokens and fans the push out to all
+ /// of them. Failures are logged but never surfaced — pushes are advisory
+ /// and the next event will retry the underlying state on its own.
func publish(
kind: String,
gameID: UUID,
@@ -121,7 +143,7 @@ final class PushClient {
guard !addressees.isEmpty else { return }
log("push(\(kind)): publishing to \(addressees.count) addressee(s)")
let addresseePayloads: [[String: Any]] = addressees.map { addressee in
- var entry: [String: Any] = ["authorID": addressee.authorID]
+ var entry: [String: Any] = ["address": addressee.address]
if let body = addressee.body { entry["body"] = body }
return entry
}
@@ -148,28 +170,28 @@ final class PushClient {
}
}
- private func register(_ triple: Registration) async throws {
+ private func register(token: String, addresses: [String]) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
request.httpMethod = "POST"
applyAuth(&request)
- let body: [String: String] = [
- "authorID": triple.authorID,
- "deviceID": triple.deviceID,
- "token": triple.token,
- "environment": environment.rawValue
+ let body: [String: Any] = [
+ "deviceID": deviceID,
+ "token": token,
+ "environment": environment.rawValue,
+ "addresses": addresses
]
- request.httpBody = try JSONEncoder().encode(body)
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await session.data(for: request)
try assert204(response)
}
- private func unregister(authorID: String) async {
+ private func unregister(addresses: [String]) async {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
request.httpMethod = "DELETE"
applyAuth(&request)
- request.httpBody = try? JSONEncoder().encode([
- "authorID": authorID,
- "deviceID": deviceID
+ request.httpBody = try? JSONSerialization.data(withJSONObject: [
+ "deviceID": deviceID,
+ "addresses": addresses
])
do {
let (_, response) = try await session.data(for: request)
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -194,6 +194,11 @@ extension SyncEngine {
let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
entity.readAt = incomingReadAt
entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record)
+ // Adopt the record's push address. For a peer this is how the sender
+ // learns where to address pushes; for our own record synced from a
+ // sibling device, it's how the account's devices converge on one
+ // per-game address (the LWW winner of this record).
+ entity.pushAddress = RecordSerializer.parsePlayerPushAddress(from: record)
if authorID == localAuthorID, let readAt = incomingReadAt {
onReadCursor(gameID, readAt)
}
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -100,6 +100,7 @@ extension SyncEngine {
selection: selection,
readAt: entity.game?.lastReadOtherMoveAt,
sessionSnapshot: entity.sessionSnapshot,
+ pushAddress: entity.pushAddress,
zone: zoneID,
systemFields: entity.ckSystemFields
)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -248,6 +248,7 @@ enum RecordSerializer {
selection: PlayerSelection?,
readAt: Date? = nil,
sessionSnapshot: Data? = nil,
+ pushAddress: String? = nil,
zone: CKRecordZone.ID,
systemFields: Data?
) -> CKRecord {
@@ -281,6 +282,11 @@ enum RecordSerializer {
} else {
record["sessionSnapshot"] = nil
}
+ if let pushAddress, !pushAddress.isEmpty {
+ record["pushAddress"] = pushAddress as CKRecordValue
+ } else {
+ record["pushAddress"] = nil
+ }
return record
}
@@ -319,6 +325,16 @@ enum RecordSerializer {
record["sessionSnapshot"] as? Data
}
+ /// Reads `pushAddress` off an inbound Player record — the per-(account,
+ /// game) capability token a co-participant uses to address a push to this
+ /// player for this game. Possession is gated by the share ACL (only
+ /// participants can read the record), so the token, not an identity, is
+ /// the authorisation. Returns `nil` for older records or a slot that has
+ /// not yet minted one.
+ static func parsePlayerPushAddress(from record: CKRecord) -> String? {
+ record["pushAddress"] as? String
+ }
+
/// Parses an incoming `Player` record name back into its `(gameID,
/// authorID)` components. Returns `nil` if the name doesn't match the
/// `player-<UUID>-<authorID>` shape.
diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift
@@ -0,0 +1,110 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Push addressing is capability-based: each shared game carries a random
+/// per-(account, game) `pushAddress` on the local author's Player record. A
+/// co-participant reads it (gated by the share ACL) to address a push, so
+/// possession of the token — not an identity — is the authorisation. These
+/// tests cover the minting side: `setPushAddress` (single game) and
+/// `reconcileLocalPushAddresses` (mint-if-absent across every shared game).
+@Suite("GameStore push addressing", .isolatedNotificationState)
+@MainActor
+struct GameStorePushAddressTests {
+
+ private static let authorID = "alice"
+ private static let puzzleSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ AB
+ CD
+ """
+
+ @discardableResult
+ private func makeGame(
+ scope: Int16,
+ in ctx: NSManagedObjectContext
+ ) throws -> UUID {
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.databaseScope = scope
+ try ctx.save()
+ return gameID
+ }
+
+ @Test("setPushAddress mints once and returns the same token thereafter")
+ func setPushAddressIsStable() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let gameID = try makeGame(scope: 1, in: persistence.viewContext)
+
+ let first = try #require(store.setPushAddress(gameID: gameID, authorID: Self.authorID))
+ #expect(!first.isEmpty)
+ let second = try #require(store.setPushAddress(gameID: gameID, authorID: Self.authorID))
+ #expect(second == first)
+ }
+
+ @Test("setPushAddress returns nil when the game is missing")
+ func setPushAddressMissingGame() {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ #expect(store.setPushAddress(gameID: UUID(), authorID: Self.authorID) == nil)
+ }
+
+ @Test("Minted tokens are URL-safe and high-entropy (distinct per game)")
+ func tokensAreUrlSafeAndDistinct() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let g1 = try makeGame(scope: 1, in: persistence.viewContext)
+ let g2 = try makeGame(scope: 1, in: persistence.viewContext)
+
+ let a1 = try #require(store.setPushAddress(gameID: g1, authorID: Self.authorID))
+ let a2 = try #require(store.setPushAddress(gameID: g2, authorID: Self.authorID))
+
+ #expect(a1 != a2)
+ let allowed = CharacterSet(charactersIn:
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
+ #expect(a1.unicodeScalars.allSatisfy(allowed.contains))
+ #expect(a2.unicodeScalars.allSatisfy(allowed.contains))
+ }
+
+ @Test("reconcileLocalPushAddresses mints for shared games only")
+ func reconcileMintsSharedGamesOnly() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let shared1 = try makeGame(scope: 1, in: persistence.viewContext)
+ let shared2 = try makeGame(scope: 1, in: persistence.viewContext)
+ _ = try makeGame(scope: 0, in: persistence.viewContext) // local-only, excluded
+
+ let result = store.reconcileLocalPushAddresses(authorID: Self.authorID)
+
+ #expect(result.addresses.count == 2)
+ #expect(Set(result.mintedGameIDs) == [shared1, shared2])
+ }
+
+ @Test("reconcileLocalPushAddresses is idempotent: a second pass mints nothing")
+ func reconcileIsIdempotent() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try makeGame(scope: 1, in: persistence.viewContext)
+
+ let first = store.reconcileLocalPushAddresses(authorID: Self.authorID)
+ #expect(first.mintedGameIDs.count == 1)
+
+ let second = store.reconcileLocalPushAddresses(authorID: Self.authorID)
+ #expect(second.mintedGameIDs.isEmpty)
+ // Same address surfaces — the stored token is reused, not regenerated.
+ #expect(second.addresses == first.addresses)
+ }
+}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -104,6 +104,43 @@ struct RecordSerializerTests {
#expect(RecordSerializer.parsePlayerReadAt(from: record) == nil)
}
+ @Test("playerRecord writes pushAddress and parses it back")
+ func playerRecordPushAddressRoundTrip() {
+ let id = UUID()
+ let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
+ let address = "abc123_-XYZ"
+ let record = RecordSerializer.playerRecord(
+ gameID: id,
+ authorID: "alice",
+ name: "Alice",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
+ selection: nil,
+ pushAddress: address,
+ zone: zone,
+ systemFields: nil
+ )
+ #expect(record["pushAddress"] as? String == address)
+ #expect(RecordSerializer.parsePlayerPushAddress(from: record) == address)
+ }
+
+ @Test("playerRecord omits pushAddress when nil or empty and parser returns nil")
+ func playerRecordPushAddressNil() {
+ let id = UUID()
+ let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
+ let record = RecordSerializer.playerRecord(
+ gameID: id,
+ authorID: "alice",
+ name: "Alice",
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
+ selection: nil,
+ pushAddress: "",
+ zone: zone,
+ systemFields: nil
+ )
+ #expect(record["pushAddress"] == nil)
+ #expect(RecordSerializer.parsePlayerPushAddress(from: record) == nil)
+ }
+
// MARK: - Ping
@Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID")
diff --git a/Worker/push-worker.js b/Worker/push-worker.js
@@ -40,30 +40,38 @@ export class PushRegistry {
async handleRegister(request) {
const body = await readJSON(request);
if (!body) return badRequest("Body must be JSON");
- const { authorID, deviceID, token, environment } = body;
- if (!authorID || !deviceID || !token) {
- return badRequest("authorID, deviceID, token required");
+ const { deviceID, token, environment, addresses } = body;
+ if (!deviceID || !token || !Array.isArray(addresses)) {
+ return badRequest("deviceID, token, addresses required");
}
if (environment !== "sandbox" && environment !== "production") {
return badRequest("environment must be 'sandbox' or 'production'");
}
- const key = `token:${authorID}:${deviceID}`;
- await this.state.storage.put(key, {
- token,
- environment,
- updatedAt: Date.now()
- });
+ // Bind this device's APNs token to each per-(account, game) address it
+ // knows. The address is the lookup key; identity never reaches the worker.
+ const updatedAt = Date.now();
+ for (const address of addresses) {
+ if (typeof address !== "string" || address.length === 0) continue;
+ await this.state.storage.put(`addr:${address}:${deviceID}`, {
+ token,
+ environment,
+ updatedAt
+ });
+ }
return new Response(null, { status: 204 });
}
async handleUnregister(request) {
const body = await readJSON(request);
if (!body) return badRequest("Body must be JSON");
- const { authorID, deviceID } = body;
- if (!authorID || !deviceID) {
- return badRequest("authorID and deviceID required");
+ const { deviceID, addresses } = body;
+ if (!deviceID || !Array.isArray(addresses)) {
+ return badRequest("deviceID and addresses required");
+ }
+ for (const address of addresses) {
+ if (typeof address !== "string" || address.length === 0) continue;
+ await this.state.storage.delete(`addr:${address}:${deviceID}`);
}
- await this.state.storage.delete(`token:${authorID}:${deviceID}`);
return new Response(null, { status: 204 });
}
@@ -93,7 +101,7 @@ export class PushRegistry {
});
if (result === "ok") delivered += 1;
else if (result === "drop") {
- await this.state.storage.delete(`token:${target.authorID}:${target.deviceID}`);
+ await this.state.storage.delete(`addr:${target.address}:${target.deviceID}`);
removed += 1;
} else {
failed += 1;
@@ -105,29 +113,14 @@ export class PushRegistry {
async resolveTargets(addressees) {
const targets = [];
for (const addressee of addressees) {
- if (!addressee || !addressee.authorID) continue;
+ if (!addressee || !addressee.address) continue;
const body = typeof addressee.body === "string" ? addressee.body : undefined;
- if (addressee.deviceID) {
- const stored = await this.state.storage.get(
- `token:${addressee.authorID}:${addressee.deviceID}`
- );
- if (stored) {
- targets.push({
- authorID: addressee.authorID,
- deviceID: addressee.deviceID,
- body,
- ...stored
- });
- }
- continue;
- }
- const map = await this.state.storage.list({
- prefix: `token:${addressee.authorID}:`
- });
+ const prefix = `addr:${addressee.address}:`;
+ const map = await this.state.storage.list({ prefix });
for (const [key, value] of map) {
- const deviceID = key.slice(`token:${addressee.authorID}:`.length);
+ const deviceID = key.slice(prefix.length);
targets.push({
- authorID: addressee.authorID,
+ address: addressee.address,
deviceID,
body,
...value