crossmate

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

commit eb7b777b68c3ca7b70e8f5843e523d0273c4477c
parent 11756da131e132018944ce1195d765592768d2ad
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 21:13:13 +0900

Gate block and left Decision adoption to the account zone

An accepted friend holds .readWrite on our private friend-<pairKey>
inbox zone, which lives in the private database alongside the account
zone. applyDecisionRecord adopted a block Decision and a left Decision
after checking only the record type, kind, and key, so a friend who
wrote a forged decision-block-<Carol> into that inbox could silently
block a third party at their own version — forcing a version fight to
undo — and a forged decision-left-<gameID> could hard-delete the user's
local participant GameEntity, a disruptive kick even though the row
self-heals on the next sync.

This commit ties both cases to the account zone. Each now guards on the
isAccountZonePrivateDecision helper, requiring the record's zone name to
match accountZoneID and the fetch to have come from the private database
scope. Both Decisions are only ever written to the account zone —
FriendController and ShareController enqueue them through
enqueueDecision and enqueueDecisionDeletion — so the gate matches the
real write path and breaks no legitimate cross-device sync; the left
case keeps its existing participant-row guard so a same-id owned copy is
never collateral.

The nickname case previously inlined the same zone-name and scope check.
It now shares the helper too, so all three self-authored cross-device
Decisions gate through one place.

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

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 19+++++++++++++++++--
MTests/Unit/RecordSerializerTests.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -1089,6 +1089,13 @@ enum RecordSerializer { switch kind { case blockDecisionKind: + // A block is the user's own choice, authoritative across their + // devices — honored only from the account zone in the private + // database. Friend zones are writable by the other participant, who + // must not be able to block (or unblock) a third party on this + // user's behalf. Same gate as `nickname`/`left`. + guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) + else { return false } // Versioned so a stale copy can't resurrect a cleared block (or // re-clear a fresh one). Payload `"0"` means unblocked; anything // else (including a legacy payload-less record) means blocked. @@ -1116,6 +1123,14 @@ enum RecordSerializer { // re-applied decision after the row is gone is a no-op. Guarded // to participant rows (databaseScope == 1) so a same-id owned // copy is never collateral. + // + // The decision itself is a self-authored cross-device signal, so + // honor it only from the account zone in the private database — a + // friend must not be able to name a `(gameID)` and evict its + // participant row out of a zone he can write. Same gate as + // `nickname`/`block`. + guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) + else { return false } guard let gameID = UUID(uuidString: key) else { return false } let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -1186,8 +1201,8 @@ enum RecordSerializer { // 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 + // Same gate as `block`/`left`. + guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) else { return false } let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "authorID == %@", key) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -956,6 +956,62 @@ struct RecordSerializerTests { #expect(existing.blockVersion == 2) } + @Test("applyDecisionRecord(.block) rejects a block written into a friend zone") + @MainActor func applyDecisionBlockRejectsFriendZone() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + // A block is the user's own choice and only travels through the account + // zone in their private database. A friend (Mallory) holds `.readWrite` + // on the pair zone Alice owns, so she could drop `decision-block-_carol` + // there — even arriving at Alice's private scope (0), it must be + // rejected purely on zone, so it can't block a third party on her behalf. + let record = RecordSerializer.decisionRecord( + kind: RecordSerializer.blockDecisionKind, + key: "_carol", + zone: friendZoneID(local: "_alice", remote: "_mallory") + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + ) + #expect(!wrote) + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + #expect(try ctx.count(for: req) == 0) + } + + @Test("applyDecisionRecord(.left) rejects a left decision written into a friend zone") + @MainActor func applyDecisionLeftRejectsFriendZone() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + // A `left` decision hard-deletes a participant game row. A friend who + // can name a live `gameID` must not be able to evict Alice's row by + // writing the decision into the pair zone he can reach — it is honored + // only from Alice's own account zone. + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared" + entity.puzzleSource = "" + entity.databaseScope = 1 + entity.createdAt = Date() + entity.updatedAt = Date() + try ctx.save() + + let record = RecordSerializer.decisionRecord( + kind: "left", + key: gameID.uuidString, + zone: friendZoneID(local: "_alice", remote: "_mallory") + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + ) + #expect(!wrote) + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + #expect(try ctx.count(for: req) == 1) + } + @Test("applyDecisionRecord(.name) rejects a friend name seen at shared scope") @MainActor func applyNameDecisionRejectsSharedScope() throws { let persistence = makeTestPersistence()