commit 7e8e9313fa55f2fc6d1e4d662c387af78e9fcb1f
parent 23499bca33023cbee34d7e0dae0ae335fe6a8026
Author: Michael Camilleri <[email protected]>
Date: Wed, 1 Jul 2026 19:49:05 +0900
Remove the raw CloudKit record editor
RecordEditorView could browse, edit, and delete any record in the
.private or .shared scope, gated only by a user-flippable debugMode
AppStorage flag (four taps on the About footer) rather than `#if DEBUG`.
Because it targeted sharedCloudDatabase, it turned every inbound
trust-boundary gap into a one-tap, no-code exploit against other users'
zones.
This commit removes it outright rather than gating it. It deletes
RecordEditorView, its SettingsView navigation link, and the five
now-orphaned ...ForEdit helpers in CloudDiagnostics that existed only to
back it (fetch/save/list-zones/query/delete). No read-only support tool
is retained.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 0 insertions(+), 484 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -57,7 +57,6 @@
328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; };
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; };
- 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF436B410916399336AC106 /* RecordEditorView.swift */; };
35D97436772257DAD3936ECB /* TipStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */; };
36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; };
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
@@ -299,7 +298,6 @@
33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadCipher.swift; sourceTree = "<group>"; };
3413F8755236FC0578AF8109 /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; };
- 3EF436B410916399336AC106 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; };
3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; };
400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; };
@@ -537,7 +535,6 @@
children = (
74C8886A66F0877858A67D62 /* AboutView.swift */,
BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */,
- 3EF436B410916399336AC106 /* RecordEditorView.swift */,
7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */,
A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */,
);
@@ -1165,7 +1162,6 @@
9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */,
D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */,
D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */,
- 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */,
C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */,
diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift
@@ -116,93 +116,6 @@ extension SyncEngine {
}
}
- /// Fetches a single record by ID for the in-app record editor. Bypasses
- /// CKSyncEngine's tracked changes — caller is responsible for triggering a
- /// reconciling fetch if the record corresponds to a tracked local entity.
- func fetchRecordForEdit(
- scope: CKDatabase.Scope,
- recordID: CKRecord.ID
- ) async throws -> CKRecord {
- let database = scope == .shared
- ? container.sharedCloudDatabase
- : container.privateCloudDatabase
- return try await database.record(for: recordID)
- }
-
- /// Saves a record edited in the in-app record editor and runs a follow-up
- /// `fetchChanges` so any locally-tracked entity picks up the new server
- /// change tag via CKSyncEngine rather than going stale.
- func saveRecordForEdit(
- scope: CKDatabase.Scope,
- record: CKRecord
- ) async throws -> CKRecord {
- let database = scope == .shared
- ? container.sharedCloudDatabase
- : container.privateCloudDatabase
- let saved = try await database.save(record)
- try? await fetchChanges(source: "record-editor")
- return saved
- }
-
- /// Lists every zone in the given scope's database for the record editor.
- /// Used to populate the zone picker without forcing the user to remember
- /// raw zone names — important on Production builds where there is no
- /// Console access.
- func listZonesForEdit(scope: CKDatabase.Scope) async throws -> [CKRecordZone.ID] {
- let database = scope == .shared
- ? container.sharedCloudDatabase
- : container.privateCloudDatabase
- let zones = try await database.allRecordZones()
- return zones
- .map(\.zoneID)
- .sorted { lhs, rhs in
- if lhs.zoneName == rhs.zoneName { return lhs.ownerName < rhs.ownerName }
- return lhs.zoneName < rhs.zoneName
- }
- }
-
- /// Enumerates records of a given type in a single zone, capped at
- /// `limit`. Client-side sort by `modificationDate` descending — newest
- /// first matches how the dedupe/overlap machinery thinks about
- /// time-ordered records. A single page is enough for ad-hoc debugging;
- /// raise `limit` if a future caller needs deeper history.
- func queryRecordsForEdit(
- scope: CKDatabase.Scope,
- zoneID: CKRecordZone.ID,
- recordType: CKRecord.RecordType,
- limit: Int = 100
- ) async throws -> [CKRecord] {
- let database = scope == .shared
- ? container.sharedCloudDatabase
- : container.privateCloudDatabase
- let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
- let result = try await database.records(
- matching: query,
- inZoneWith: zoneID,
- resultsLimit: limit
- )
- let records = result.matchResults.compactMap { _, recordResult in
- try? recordResult.get()
- }
- return records.sorted {
- ($0.modificationDate ?? .distantPast) > ($1.modificationDate ?? .distantPast)
- }
- }
-
- /// Deletes a record by ID via the in-app record editor. Matches the
- /// editor's bypass-CKSyncEngine pattern; callers that maintain local
- /// state for the record are responsible for reconciling separately.
- func deleteRecordForEdit(
- scope: CKDatabase.Scope,
- recordID: CKRecord.ID
- ) async throws {
- let database = scope == .shared
- ? container.sharedCloudDatabase
- : container.privateCloudDatabase
- _ = try await database.deleteRecord(withID: recordID)
- try? await fetchChanges(source: "record-editor-delete")
- }
-
nonisolated func describe(_ error: Error) -> String {
let nsError = error as NSError
return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
diff --git a/Crossmate/Views/Settings/RecordEditorView.swift b/Crossmate/Views/Settings/RecordEditorView.swift
@@ -1,390 +0,0 @@
-import CloudKit
-import SwiftUI
-
-/// In-app substitute for CloudKit Console for accounts that lack developer
-/// console access. Fetches a record by `(scope, zone, owner, recordName)`,
-/// displays its fields, allows editing String fields and adding new String
-/// fields, and saves back via the CKDatabase API. Non-String fields are shown
-/// read-only — extend the type dispatch below if a future migration needs to
-/// edit numbers, dates, etc.
-struct RecordEditorView: View {
- @Environment(\.syncEngine) private var syncEngine
-
- @State private var scope: CKDatabase.Scope = .private
- @State private var zoneName: String = ""
- @State private var ownerName: String = ""
- @State private var recordName: String = ""
-
- @State private var record: CKRecord?
- @State private var stringEdits: [String: String] = [:]
- @State private var newFieldName: String = ""
- @State private var newFieldValue: String = ""
-
- @State private var status: String = ""
- @State private var isWorking: Bool = false
-
- @State private var browseRecordType: String = "Ping"
- @State private var browsedZones: [CKRecordZone.ID] = []
- @State private var browsedRecords: [CKRecord] = []
-
- var body: some View {
- Form {
- Section("Lookup") {
- Picker("Scope", selection: $scope) {
- Text("Private").tag(CKDatabase.Scope.private)
- Text("Shared").tag(CKDatabase.Scope.shared)
- }
- .pickerStyle(.segmented)
- plainField("Zone name", text: $zoneName)
- plainField("Owner (blank = self)", text: $ownerName)
- plainField("Record name", text: $recordName)
- Button {
- Task { await fetch() }
- } label: {
- HStack {
- Text("Fetch")
- if isWorking {
- Spacer()
- ProgressView()
- }
- }
- }
- .disabled(isWorking || zoneName.isEmpty || recordName.isEmpty)
- }
-
- Section("Browse zones") {
- Button {
- Task { await listZones() }
- } label: {
- Text("List zones in \(scope == .shared ? "shared" : "private") database")
- }
- .disabled(isWorking)
-
- if !browsedZones.isEmpty {
- ForEach(browsedZones, id: \.self) { zoneID in
- Button {
- zoneName = zoneID.zoneName
- ownerName = zoneID.ownerName == CKCurrentUserDefaultName
- ? ""
- : zoneID.ownerName
- browsedRecords = []
- } label: {
- VStack(alignment: .leading, spacing: 2) {
- Text(zoneID.zoneName)
- .font(.caption.monospaced())
- .foregroundStyle(.primary)
- Text("owner: \(zoneID.ownerName)")
- .font(.caption2.monospaced())
- .foregroundStyle(.secondary)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
- }
- }
-
- Section("Browse records") {
- plainField("Record type (e.g. Ping)", text: $browseRecordType)
- Button {
- Task { await listRecords() }
- } label: {
- Text("List records in zone")
- }
- .disabled(isWorking || zoneName.isEmpty || browseRecordType.isEmpty)
-
- if !browsedRecords.isEmpty {
- ForEach(browsedRecords, id: \.recordID) { record in
- BrowsedRecordRow(record: record) {
- recordName = record.recordID.recordName
- Task { await fetch() }
- }
- .swipeActions(edge: .trailing) {
- Button(role: .destructive) {
- Task { await deleteBrowsedRecord(record) }
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- }
- }
- }
-
- if let record {
- Section("Fields") {
- let keys = record.allKeys().sorted()
- if keys.isEmpty {
- Text("(no fields)").foregroundStyle(.secondary)
- } else {
- ForEach(keys, id: \.self) { key in
- FieldRow(
- key: key,
- value: record[key],
- stringBinding: stringBinding(for: key, in: record)
- )
- }
- }
- }
-
- Section("Add string field") {
- plainField("Name", text: $newFieldName)
- plainField("Value", text: $newFieldValue)
- }
-
- Section("Metadata") {
- metaRow("Type", record.recordType)
- metaRow("Zone", record.recordID.zoneID.zoneName)
- metaRow("Owner", record.recordID.zoneID.ownerName)
- if let mod = record.modificationDate {
- metaRow("Modified", ISO8601DateFormatter().string(from: mod))
- }
- if let tag = record.recordChangeTag {
- metaRow("ChangeTag", tag)
- }
- }
-
- Section {
- Button("Save changes") {
- Task { await save() }
- }
- .disabled(isWorking || !hasPendingEdits)
- }
- }
-
- if !status.isEmpty {
- Section("Status") {
- Text(status)
- .font(.body.monospaced())
- .textSelection(.enabled)
- }
- }
- }
- .navigationTitle("Record Editor")
- .navigationBarTitleDisplayMode(.inline)
- }
-
- // MARK: - Subviews
-
- @ViewBuilder
- private func plainField(_ placeholder: String, text: Binding<String>) -> some View {
- TextField(placeholder, text: text)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .font(.body.monospaced())
- }
-
- @ViewBuilder
- private func metaRow(_ title: String, _ value: String) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(title).font(.caption).foregroundStyle(.secondary)
- Text(value).font(.caption.monospaced()).textSelection(.enabled)
- }
- }
-
- // MARK: - Bindings / state
-
- private var hasPendingEdits: Bool {
- !stringEdits.isEmpty || !newFieldName.isEmpty
- }
-
- private func stringBinding(for key: String, in record: CKRecord) -> Binding<String>? {
- guard record[key] is String else { return nil }
- return Binding(
- get: { stringEdits[key] ?? (record[key] as? String) ?? "" },
- set: { stringEdits[key] = $0 }
- )
- }
-
- // MARK: - Actions
-
- private func fetch() async {
- guard let syncEngine else { return }
- isWorking = true
- defer { isWorking = false }
- status = "Fetching…"
- do {
- let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName
- let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
- let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
- let fetched = try await syncEngine.fetchRecordForEdit(
- scope: scope,
- recordID: recordID
- )
- record = fetched
- stringEdits = [:]
- newFieldName = ""
- newFieldValue = ""
- status = "Fetched."
- } catch {
- record = nil
- status = describe(error)
- }
- }
-
- private func save() async {
- guard let syncEngine, let record else { return }
- isWorking = true
- defer { isWorking = false }
- status = "Saving…"
-
- for (key, value) in stringEdits {
- record[key] = value.isEmpty ? nil : (value as CKRecordValue)
- }
- let trimmedNewName = newFieldName.trimmingCharacters(in: .whitespaces)
- if !trimmedNewName.isEmpty {
- record[trimmedNewName] = newFieldValue.isEmpty
- ? nil
- : (newFieldValue as CKRecordValue)
- }
-
- do {
- let saved = try await syncEngine.saveRecordForEdit(scope: scope, record: record)
- self.record = saved
- stringEdits = [:]
- newFieldName = ""
- newFieldValue = ""
- status = "Saved."
- } catch {
- status = describe(error)
- }
- }
-
- private func listZones() async {
- guard let syncEngine else { return }
- isWorking = true
- defer { isWorking = false }
- status = "Listing zones…"
- do {
- browsedZones = try await syncEngine.listZonesForEdit(scope: scope)
- status = "Found \(browsedZones.count) zone(s)."
- } catch {
- browsedZones = []
- status = describe(error)
- }
- }
-
- private func listRecords() async {
- guard let syncEngine else { return }
- isWorking = true
- defer { isWorking = false }
- status = "Listing records…"
- let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName
- let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
- do {
- browsedRecords = try await syncEngine.queryRecordsForEdit(
- scope: scope,
- zoneID: zoneID,
- recordType: browseRecordType
- )
- status = "Found \(browsedRecords.count) \(browseRecordType) record(s)."
- } catch {
- browsedRecords = []
- status = describe(error)
- }
- }
-
- private func deleteBrowsedRecord(_ record: CKRecord) async {
- guard let syncEngine else { return }
- isWorking = true
- defer { isWorking = false }
- status = "Deleting \(record.recordID.recordName)…"
- do {
- try await syncEngine.deleteRecordForEdit(scope: scope, recordID: record.recordID)
- browsedRecords.removeAll { $0.recordID == record.recordID }
- if self.record?.recordID == record.recordID {
- self.record = nil
- stringEdits = [:]
- }
- status = "Deleted \(record.recordID.recordName)."
- } catch {
- status = describe(error)
- }
- }
-
- private func describe(_ error: Error) -> String {
- let nsError = error as NSError
- return "Error: domain=\(nsError.domain) code=\(nsError.code) — \(nsError.localizedDescription)"
- }
-}
-
-private struct BrowsedRecordRow: View {
- let record: CKRecord
- let onTap: () -> Void
-
- var body: some View {
- Button(action: onTap) {
- VStack(alignment: .leading, spacing: 2) {
- Text(record.recordID.recordName)
- .font(.caption.monospaced())
- .foregroundStyle(.primary)
- .lineLimit(2)
- HStack(spacing: 8) {
- if let kind = record["kind"] as? String {
- Text("kind=\(kind)")
- .font(.caption2.monospaced())
- .foregroundStyle(.secondary)
- }
- if let mod = record.modificationDate {
- Text(ISO8601DateFormatter().string(from: mod))
- .font(.caption2.monospaced())
- .foregroundStyle(.secondary)
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-}
-
-private struct FieldRow: View {
- let key: String
- let value: CKRecordValue?
- let stringBinding: Binding<String>?
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text(key).font(.caption.bold())
- Spacer()
- Text(typeName).font(.caption2).foregroundStyle(.secondary)
- }
- if let stringBinding {
- TextField("", text: stringBinding, axis: .vertical)
- .font(.body.monospaced())
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- } else {
- Text(displayValue)
- .font(.body.monospaced())
- .textSelection(.enabled)
- .foregroundStyle(.secondary)
- }
- }
- }
-
- private var typeName: String {
- guard let value else { return "nil" }
- if value is String { return "String" }
- if value is Date { return "Date" }
- if value is Data { return "Data" }
- if value is CKAsset { return "Asset" }
- if value is CKRecord.Reference { return "Reference" }
- if value is [Any] { return "Array" }
- if value is NSNumber { return "Number" }
- return String(describing: type(of: value))
- }
-
- private var displayValue: String {
- guard let value else { return "(nil)" }
- if let s = value as? String { return s }
- if let d = value as? Date { return ISO8601DateFormatter().string(from: d) }
- if let data = value as? Data { return "\(data.count) bytes" }
- if let asset = value as? CKAsset {
- return "asset(\(asset.fileURL?.lastPathComponent ?? "?"))"
- }
- if let ref = value as? CKRecord.Reference {
- return "ref(\(ref.recordID.recordName))"
- }
- if let arr = value as? [Any] { return "[\(arr.count) items]" }
- return "\(value)"
- }
-}
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -91,9 +91,6 @@ struct SettingsView: View {
NavigationLink("Diagnostics Log") {
DiagnosticsView()
}
- NavigationLink("Record Editor") {
- RecordEditorView()
- }
Button("Reset Database", role: .destructive) {
showResetConfirmation = true