commit d110bbfeae5cf00a8938170d10bac1da2c1b663d
parent a7cc5c0783914d5eb90051bf09decaadaa30ae94
Author: Michael Camilleri <[email protected]>
Date: Fri, 5 Jun 2026 19:31:15 +0900
Derive per-game push addresses from one account secret
Per-game push addresses were minted as random per-device tokens and
converged across the account's own devices through the shared Player
record. That record is multi-writer (every device writes it) and lives
in a peer's zone, so a device that hadn't synced its own row would mint
a competing address and enqueue a stripped Player record — which both
collided as an insert (CKErrorDomain 14, 'record to insert already
exists') and, on the oplock-recovery retry, clobbered the server's name
and readAt and flipped the converged address to a divergent value. The
account's devices then ping-ponged the address and some stopped being
reachable.
This commit replaces the per-device mint with a deterministic
derivation:
address = HMAC-SHA256(accountSecret, gameID)
The only converged state is one account push secret, minted once and
shared across the account's own devices via a Decision in the private
account zone (the same mechanism the account address already uses). It is
never sent to peers or the push worker, so the per-game scoping holds —
a peer holding one game's address cannot compute another's. Because every
device derives the identical address, there is nothing to negotiate: no
minting, no convergence race, no clobber. Rotation is by changing the
secret, which re-derives every game's address.
All enqueues still drain via sendChangesDetached.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
7 files changed, 300 insertions(+), 139 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -829,10 +829,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)
+ // Stamp this game's derived push address 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.setDerivedPushAddress(gameID: gameID, authorID: authorID)
await services.publishReadCursor(for: gameID, mode: .activeLease)
await services.playerNamePublisher?.publishName(for: gameID)
await selectionPublisher.begin(
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1158,15 +1158,15 @@ 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.
+ /// Stamps the local author's Player record for `gameID` with the address
+ /// derived from `secret` (see `RecordSerializer.deriveGameAddress`), so it
+ /// ships on the Player-record write the puzzle-open burst is already making.
+ /// Returns the derived address, or `nil` if the GameEntity is missing.
+ /// Creating the row here is safe because the open burst fills its `name` and
+ /// `readAt` before the send — unlike the standalone registration sweep
+ /// (`reconcileLocalPushAddresses`), which must never fabricate a bare row.
@discardableResult
- func setPushAddress(gameID: UUID, authorID: String) -> String? {
+ func setPushAddress(gameID: UUID, authorID: String, secret: String) -> String? {
let entity: PlayerEntity
if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
entity = existing
@@ -1184,77 +1184,57 @@ final class GameStore {
)
entity.updatedAt = Date()
}
- if let existing = entity.pushAddress, !existing.isEmpty {
- return existing
- }
- let address = Self.makePushAddress()
+ let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID)
+ guard entity.pushAddress != address else { return address }
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.
+ // Bump updatedAt so the derived address wins LWW and 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.
+ /// Derives the local author's push address for every shared game the account
+ /// participates in (`HMAC(secret, gameID)`), and returns the full set of
+ /// those addresses together with the games whose existing Player row was
+ /// updated to a new value (so the caller can republish them for peers). The
+ /// caller registers the whole set with the push worker. Because the address
+ /// is derived — identical on every device — there's nothing to mint and no
+ /// convergence to negotiate; this only *writes back* the derived value onto
+ /// rows that already exist, and never fabricates a bare row (which would
+ /// clobber `name`/`readAt` server-side). A game with no local row still
+ /// contributes its derived address to the registration set: a peer learns it
+ /// from whichever of the account's devices does publish that row, all of
+ /// which derive the same value.
func reconcileLocalPushAddresses(
- authorID: String
- ) -> (addresses: Set<String>, mintedGameIDs: [UUID]) {
+ authorID: String,
+ secret: String
+ ) -> (addresses: Set<String>, republishGameIDs: [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
+ var republishGameIDs: [UUID] = []
+ var didChange = 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
- }
+ let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID)
+ addresses.insert(address)
+ // Update an existing row in place; never create one here.
+ guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID),
+ player.pushAddress != address
+ else { continue }
+ player.pushAddress = address
+ player.updatedAt = Date()
+ republishGameIDs.append(gameID)
+ didChange = true
}
- if didMint {
+ if didChange {
saveContext("reconcileLocalPushAddresses")
}
- return (addresses, mintedGameIDs)
+ return (addresses, republishGameIDs)
}
/// Player record `updatedAt` for `(gameID, authorID)` — `nil` if no row
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -38,6 +38,7 @@ final class AppServices {
/// caps any actual re-hail rate.
private static let engagementReconnectInterval: Duration = .seconds(30)
private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
+ private static let accountPushSecretDefaultsPrefix = "push.accountSecret."
private static let accountJoinedPushKind = "accountJoined"
private static let accountSeenPushKind = "accountSeen"
@@ -282,33 +283,6 @@ final class AppServices {
syncEngine: syncEngine,
syncMonitor: self.syncMonitor
)
- let pushClient = self.pushClient
- self.shareController.onShareSaved = { [store] gameID in
- store.markShared(gameID: gameID)
- // The CKSyncEngine drain kicked by `enqueuePlayer` is detached
- // inside SyncEngine. Keep this hop MainActor-bound because these
- // collaborators are main-actor app state, not Sendable payloads.
- Task { @MainActor [preferences, identity, store, syncEngine, pushClient] in
- guard preferences.isICloudSyncEnabled,
- 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)
- }
- // Register the app for notifications now that the user has chosen
- // to collaborate. Surfaces the app in Settings > Notifications and
- // makes the icon-badge permission available before any inbound
- // moves can arrive.
- Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
- }
self.playerSelectionPublisher = PlayerSelectionPublisher(
// While the live room carries the cursor over the websocket, the
// durable write is a lagging fallback — throttle it hard. When the
@@ -511,6 +485,32 @@ final class AppServices {
await self.reconcilePushRegistration()
}
+ await syncEngine.setOnAccountPushSecret { [weak self] secret in
+ guard let self,
+ let authorID = self.identity.currentID,
+ !authorID.isEmpty else { return }
+ // Adopting a sibling's secret (or a rotation) changes every derived
+ // address; reconcile re-derives, rewrites the local Player rows, and
+ // re-registers the new set with the worker.
+ self.cacheAccountPushSecret(secret, authorID: authorID)
+ await self.reconcilePushRegistration()
+ }
+
+ shareController.onShareSaved = { [weak self] gameID in
+ guard let self else { return }
+ self.store.markShared(gameID: gameID)
+ // Register this device under the newly-shared game's derived push
+ // address so peers can reach it.
+ Task { @MainActor [weak self] in
+ await self?.reconcilePushRegistration()
+ }
+ // Register the app for notifications now that the user has chosen
+ // to collaborate. Surfaces the app in Settings > Notifications and
+ // makes the icon-badge permission available before any inbound
+ // moves can arrive.
+ Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
+ }
+
await syncEngine.setOnPings { [weak self] pings in
guard let self else { return }
await self.presentPings(pings)
@@ -742,8 +742,9 @@ final class AppServices {
guard let pushClient, preferences.isICloudSyncEnabled else { return }
guard let authorID = identity.currentID, !authorID.isEmpty else { return }
let accountAddress = ensureAccountPushAddress(authorID: authorID)
- let result = store.reconcileLocalPushAddresses(authorID: authorID)
- for gameID in result.mintedGameIDs {
+ let secret = ensureAccountPushSecret(authorID: authorID)
+ let result = store.reconcileLocalPushAddresses(authorID: authorID, secret: secret)
+ for gameID in result.republishGameIDs {
await syncEngine.enqueuePlayer(
gameID: gameID,
authorID: authorID,
@@ -753,6 +754,15 @@ final class AppServices {
pushClient.setAddresses(result.addresses.union([accountAddress]))
}
+ /// Stamps `gameID`'s local Player row with the derived push address inside
+ /// the puzzle-open send burst, so it ships on the same Player-record write
+ /// as the read-cursor lease and display name.
+ @discardableResult
+ func setDerivedPushAddress(gameID: UUID, authorID: String) -> String? {
+ let secret = ensureAccountPushSecret(authorID: authorID)
+ return store.setPushAddress(gameID: gameID, authorID: authorID, secret: secret)
+ }
+
private func ensureAccountPushAddress(authorID: String) -> String {
let key = accountPushAddressDefaultsKey(authorID: authorID)
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
@@ -779,6 +789,45 @@ final class AppServices {
Self.accountPushAddressDefaultsPrefix + authorID
}
+ /// Mints (if needed) the account-wide push secret — the HMAC key every
+ /// per-game push address is derived from. Converges across the account's own
+ /// devices through a `Decision` exactly like the account address; never sent
+ /// to peers or the worker, so only the account's devices can derive.
+ private func ensureAccountPushSecret(authorID: String) -> String {
+ let key = accountPushSecretDefaultsKey(authorID: authorID)
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
+ return existing
+ }
+ var bytes = [UInt8](repeating: 0, count: 32)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ let secret = Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ UserDefaults.standard.set(secret, forKey: key)
+ publishAccountPushSecretDecision(secret)
+ return secret
+ }
+
+ private func cacheAccountPushSecret(_ secret: String, authorID: String) {
+ guard !secret.isEmpty else { return }
+ UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ }
+
+ private func accountPushSecretDefaultsKey(authorID: String) -> String {
+ Self.accountPushSecretDefaultsPrefix + authorID
+ }
+
+ private func publishAccountPushSecretDecision(_ secret: String) {
+ Task.detached { [syncEngine] in
+ await syncEngine.enqueueDecision(
+ kind: RecordSerializer.accountDecisionKind,
+ key: RecordSerializer.accountPushSecretDecisionKey,
+ payload: secret
+ )
+ }
+ }
+
private func publishAccountPushAddressDecision(_ address: String) {
// This can be reached from callbacks that SyncEngine invokes while a
// CKSyncEngine delegate method is still unwinding. Match the existing
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -29,6 +29,10 @@ struct BatchEffects {
var journalsSynced = Set<UUID>()
/// Account-level push address decisions seen in the private account zone.
var accountPushAddresses: [String] = []
+ /// Account-level push *secret* decisions seen in the private account zone.
+ /// Drive re-derivation of every per-game push address (see
+ /// `RecordSerializer.deriveGameAddress`).
+ var accountPushSecrets: [String] = []
}
extension SyncEngine {
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -1,5 +1,6 @@
import CloudKit
import CoreData
+import CryptoKit
import Foundation
/// Pure-function helpers for converting between the app's Core Data / in-memory
@@ -100,11 +101,21 @@ enum RecordSerializer {
static let accountDecisionKind = "account"
static let accountPushAddressDecisionKey = "pushAddress"
+ /// Key for the account-wide push *secret* decision. The secret is the HMAC
+ /// key from which every per-game push address is derived (see
+ /// `deriveGameAddress`); it converges across the account's own devices the
+ /// same way the account address does, and is never sent to peers or the
+ /// push worker — only the derived per-game addresses are.
+ static let accountPushSecretDecisionKey = "pushSecret"
static var accountPushAddressDecisionName: String {
decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey)
}
+ static var accountPushSecretDecisionName: String {
+ decisionRecordName(kind: accountDecisionKind, key: accountPushSecretDecisionKey)
+ }
+
static func parseAccountPushAddressDecision(_ record: CKRecord) -> String? {
guard record.recordType == "Decision",
record.recordID.recordName == accountPushAddressDecisionName,
@@ -115,6 +126,34 @@ enum RecordSerializer {
return address
}
+ static func parseAccountPushSecretDecision(_ record: CKRecord) -> String? {
+ guard record.recordType == "Decision",
+ record.recordID.recordName == accountPushSecretDecisionName,
+ (record["kind"] as? String) == accountDecisionKind,
+ let secret = record["payload"] as? String,
+ !secret.isEmpty
+ else { return nil }
+ return secret
+ }
+
+ /// Derives this account's push address for one game as
+ /// `HMAC-SHA256(secret, gameID)`, base64url-encoded. Deterministic, so every
+ /// one of the account's devices computes the identical address for a game
+ /// without any negotiation, and per-game scoped: a peer holding one game's
+ /// address can't compute another's without the secret, which never leaves
+ /// the account's devices. Rotation is by changing the secret.
+ static func deriveGameAddress(secret: String, gameID: UUID) -> String {
+ let key = SymmetricKey(data: Data(secret.utf8))
+ let mac = HMAC<SHA256>.authenticationCode(
+ for: Data(gameID.uuidString.utf8),
+ using: key
+ )
+ return Data(mac).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
// MARK: - Zone
/// Zone ID for a per-game zone. `ownerName` defaults to the current user
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -116,6 +116,7 @@ actor SyncEngine {
/// future and later close it with a lower current-time value.
var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)?
var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)?
+ var onAccountPushSecret: (@MainActor @Sendable (String) async -> Void)?
private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
/// Fires when a delegate event reports a successful round-trip with the
@@ -203,6 +204,10 @@ actor SyncEngine {
onAccountPushAddress = cb
}
+ func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String) async -> Void) {
+ onAccountPushSecret = cb
+ }
+
func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
localAuthorIDProvider = cb
}
@@ -1258,6 +1263,9 @@ actor SyncEngine {
if let address = RecordSerializer.parseAccountPushAddressDecision(record) {
effects.accountPushAddresses.append(address)
}
+ if let secret = RecordSerializer.parseAccountPushSecretDecision(record) {
+ effects.accountPushSecrets.append(secret)
+ }
let wrote = RecordSerializer.applyDecisionRecord(
record,
to: ctx,
@@ -1345,6 +1353,16 @@ actor SyncEngine {
await onAccountPushAddress(address)
}
}
+ // The push secret converges across the account's own devices purely
+ // through this inbound path (the account zone lives in the private DB,
+ // which reliably syncs to every one of the account's devices). On a
+ // simultaneous-mint race the loser adopts the winner's secret here on
+ // the next fetch — no send-failure-recovery shortcut needed.
+ if let onAccountPushSecret {
+ for secret in effects.accountPushSecrets {
+ await onAccountPushSecret(secret)
+ }
+ }
for id in effects.removed {
if let cb = onGameRemoved { await cb(id) }
}
diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift
@@ -1,20 +1,23 @@
+import CloudKit
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).
+/// Push addressing is capability-based, but the per-(account, game) address is
+/// now *derived* — `HMAC(accountSecret, gameID)` — rather than minted per
+/// device. Every one of the account's devices computes the identical address
+/// for a game, so there's nothing to converge and no random token to clobber.
+/// These tests cover the derivation (`RecordSerializer.deriveGameAddress`), the
+/// open-burst stamp (`setPushAddress`), and the registration sweep
+/// (`reconcileLocalPushAddresses`) — which must never fabricate a bare row.
@Suite("GameStore push addressing", .isolatedNotificationState)
@MainActor
struct GameStorePushAddressTests {
private static let authorID = "alice"
+ private static let secret = "test-secret-0123456789"
private static let puzzleSource = """
Title: Test Puzzle
Author: Test
@@ -43,15 +46,72 @@ struct GameStorePushAddressTests {
return gameID
}
- @Test("setPushAddress mints once and returns the same token thereafter")
- func setPushAddressIsStable() throws {
+ /// Inserts a Player row carrying a stale (pre-derivation) address, standing
+ /// in for a record that synced before this build.
+ private func makeStalePlayerRow(
+ gameID: UUID,
+ address: String,
+ in ctx: NSManagedObjectContext
+ ) throws {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ let game = try #require(try ctx.fetch(req).first)
+ let player = PlayerEntity(context: ctx)
+ player.game = game
+ player.authorID = Self.authorID
+ player.name = "alice"
+ player.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: Self.authorID
+ )
+ player.pushAddress = address
+ player.updatedAt = Date()
+ try ctx.save()
+ }
+
+ private func playerRowCount(in ctx: NSManagedObjectContext) throws -> Int {
+ try ctx.count(for: NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity"))
+ }
+
+ // MARK: - Derivation
+
+ @Test("deriveGameAddress is deterministic, URL-safe, and per-game/per-secret distinct")
+ func derivationProperties() {
+ let g1 = UUID()
+ let g2 = UUID()
+
+ let a1 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1)
+ let a1Again = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1)
+ let a2 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g2)
+ let a1OtherSecret = RecordSerializer.deriveGameAddress(secret: "other-secret", gameID: g1)
+
+ #expect(a1 == a1Again) // deterministic
+ #expect(a1 != a2) // scoped per game
+ #expect(a1 != a1OtherSecret) // scoped per secret
+ #expect(!a1.isEmpty)
+
+ let allowed = CharacterSet(charactersIn:
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
+ #expect(a1.unicodeScalars.allSatisfy(allowed.contains))
+ }
+
+ // MARK: - setPushAddress (open burst)
+
+ @Test("setPushAddress stamps the derived address and is stable")
+ func setPushAddressDerivesStably() 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))
+ let expected = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID)
+ let first = try #require(
+ store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret)
+ )
+ #expect(first == expected)
+ let second = try #require(
+ store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret)
+ )
#expect(second == first)
}
@@ -59,52 +119,63 @@ struct GameStorePushAddressTests {
func setPushAddressMissingGame() {
let persistence = makeTestPersistence()
let store = makeTestStore(persistence: persistence)
- #expect(store.setPushAddress(gameID: UUID(), authorID: Self.authorID) == nil)
+ #expect(
+ store.setPushAddress(gameID: UUID(), authorID: Self.authorID, secret: Self.secret) == 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))
- }
+ // MARK: - reconcileLocalPushAddresses (registration sweep)
- @Test("reconcileLocalPushAddresses mints for shared games only")
- func reconcileMintsSharedGamesOnly() throws {
+ @Test("reconcile derives shared games only, and never fabricates a Player row")
+ func reconcileDerivesSharedGamesWithoutFabricating() 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])
+ let result = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret)
+
+ // Both shared games contribute their derived address for registration,
+ // even with no local Player row to publish from.
+ #expect(result.addresses == [
+ RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared1),
+ RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared2)
+ ])
+ // No existing rows, so nothing to republish — and crucially, no bare row
+ // was fabricated (the CKError 14 / clobber regression).
+ #expect(result.republishGameIDs.isEmpty)
+ #expect(try playerRowCount(in: persistence.viewContext) == 0)
}
- @Test("reconcileLocalPushAddresses is idempotent: a second pass mints nothing")
- func reconcileIsIdempotent() throws {
+ @Test("reconcile migrates a stale row to the derived address and lists it")
+ func reconcileMigratesStaleRow() 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)
+ let gameID = try makeGame(scope: 1, in: persistence.viewContext)
+ try makeStalePlayerRow(
+ gameID: gameID,
+ address: "stale-minted-token",
+ in: persistence.viewContext
+ )
+
+ let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID)
+ let first = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret)
+ #expect(first.addresses == [derived])
+ #expect(first.republishGameIDs == [gameID])
+
+ let player = try #require(
+ persistence.viewContext.registeredObjects
+ .compactMap { $0 as? PlayerEntity }
+ .first
+ )
+ #expect(player.pushAddress == derived)
+ // The migration must not have wiped the row's display name.
+ #expect(player.name == "alice")
+
+ // Idempotent: the address now matches, so nothing is republished.
+ let second = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret)
+ #expect(second.addresses == [derived])
+ #expect(second.republishGameIDs.isEmpty)
}
}