crossmate

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

commit 32997a08f7f55e0994bb5c4574c90132a517cb45
parent c10e1fd9bbe85fe934ed35144277394f41809e80
Author: Michael Camilleri <[email protected]>
Date:   Sat, 13 Jun 2026 22:15:49 +0900

Fix nickname Decisions never syncing across a user's devices

The nickname apply path gated on `record.recordID.zoneID ==
accountZoneID`, a full zone-ID equality that includes the owner name.
The account zone is created with the `CKCurrentUserDefaultName`
placeholder owner, but a record fetched back from CloudKit carries the
concrete user-record ID as its owner.  The `==` could therefore be false
on the receiving device and every synced nickname was silently dropped —
set locally, never applied anywhere else.

This was unique to nickname: block, the push decisions and name all
avoid full-zone-ID equality, which is why only nicknames failed to sync.

Match on zone name + private database scope instead, mirroring the name
case. The private-scope gate preserves the anti-relabel guarantee — a
friend can only reach us through the shared DB, so an 'account'-named
zone they share in is rejected by scope rather than by the brittle
owner-name check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 17+++++++++++++----
MTests/Unit/RecordSerializerTests.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -990,10 +990,19 @@ enum RecordSerializer { return true case nicknameDecisionKind: // The user's private nickname for a friend, authoritative across - // their own devices. Honored only from the account zone: friend - // zones are writable by the other participant, who must not be - // able to relabel people in this user's friends list. - guard record.recordID.zoneID == accountZoneID else { return false } + // their own devices. Honored only from the account zone in our own + // private database: friend zones are writable by the other + // participant, who must not be able to relabel people in this + // user's friends list. Match on zone *name* + private scope, not a + // full `zoneID ==`: a record fetched back from CloudKit does not + // reliably carry the `CKCurrentUserDefaultName` owner placeholder + // `accountZoneID` is built with — its `ownerName` often comes back + // as the concrete user-record ID, so an `==` silently rejects every + // synced nickname. Scoping to the private DB keeps the anti-relabel + // guarantee (a friend can only reach us through the shared DB). + guard record.recordID.zoneID.zoneName == accountZoneID.zoneName, + databaseScope == 0 + else { return false } let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "authorID == %@", key) req.fetchLimit = 1 diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -960,6 +960,54 @@ struct RecordSerializerTests { #expect(friend.nickname?.isEmpty != false) } + @Test("applyDecisionRecord(.nickname) applies a record fetched with a concrete owner name") + @MainActor func applyNicknameDecisionConcreteOwnerName() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + + // A Decision written with the `CKCurrentUserDefaultName` placeholder + // comes back from CloudKit with the concrete user-record ID as its + // zone owner. The apply path must still recognise it as the account + // zone (zone *name* + private scope), or no nickname ever syncs. + let fetchedZone = CKRecordZone.ID( + zoneName: RecordSerializer.accountZoneID.zoneName, + ownerName: "_alice" + ) + let record = nicknameDecisionRecord( + subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + ) + #expect(wrote) + #expect(friend.nickname == "Bobby") + #expect(friend.nicknameVersion == 1) + } + + @Test("applyDecisionRecord(.nickname) rejects an account-named zone in the shared DB") + @MainActor func applyNicknameDecisionRejectsSharedAccountZone() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") + + // A friend cannot reach our private DB; spoofing the relabel means + // sharing a zone they named "account" into our *shared* DB. The + // private-scope gate must reject it even though the zone name matches. + let spoofZone = CKRecordZone.ID( + zoneName: RecordSerializer.accountZoneID.zoneName, + ownerName: "_bob" + ) + let record = nicknameDecisionRecord( + subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice", databaseScope: 1 + ) + #expect(!wrote) + #expect(friend.nickname?.isEmpty != false) + } + @Test("applyDecisionRecord(.nickname) writes no row for an unknown friend") @MainActor func applyNicknameDecisionSkipsUnknownFriend() throws { let persistence = makeTestPersistence()