crossmate

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

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:
MCrossmate/CrossmateApp.swift | 8++++----
MCrossmate/Persistence/GameStore.swift | 102++++++++++++++++++++++++++++++++-----------------------------------------------
MCrossmate/Services/AppServices.swift | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
MCrossmate/Sync/RecordApplier.swift | 4++++
MCrossmate/Sync/RecordSerializer.swift | 39+++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 18++++++++++++++++++
MTests/Unit/GameStorePushAddressTests.swift | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
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) } }