Native synct bisher per Default beim Login, Web ist Opt-in. Neues EventSyncConfig.requireSyncConsent (Default false → abwärtskompatibel): ist es gesetzt, läuft die Engine ohne explizite Einwilligung lokal-only (authMode() → anonymous trotz JWT, kein Egress, Outbox staut). Einwilligung beim start() aus mana-auth /api/v1/settings (gleiche Quelle wie Web), setSyncConsent(_:) als Konto-Toggle (true claimt+startet wie signIn, false stoppt Server-Sync; lokale Daten bleiben). + syncConsentRequired/hasSyncConsent. Tests: SyncConsentTests (6) grün, volle Suite ohne Regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.9 KiB
Swift
121 lines
4.9 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Konfiguration einer ``EventSyncEngine``. Trägt alles App-Spezifische
|
|
/// (appId, URLs, Store-Name, App-Group, app-eigene `@Model`-Typen,
|
|
/// Feature-Flags), damit die Engine selbst app-agnostisch bleibt.
|
|
public struct EventSyncConfig {
|
|
public let appId: String
|
|
public let syncURL: URL
|
|
public let authBaseURL: URL
|
|
public let schemaHash: String?
|
|
public let storeName: String
|
|
public let appGroupIdentifier: String?
|
|
public let additionalModels: [any PersistentModel.Type]
|
|
public let enableWebSocket: Bool
|
|
public let enableEncryption: Bool
|
|
/// Crypto-Shredding (A3): bildet eine aggregateId auf eine scopeId ab
|
|
/// (z.B. `mandant:<id>`). Gesetzt (+ `enableEncryption`) → die Engine nutzt
|
|
/// einen ``ScopedCryptoProvider`` (Per-Scope-Sub-Keys) statt nur des
|
|
/// Master-Keys. `nil` → reiner Master-Key (Default, abwärtskompatibel).
|
|
public let scopeResolver: (@Sendable (String) -> String?)?
|
|
public let pollInterval: TimeInterval
|
|
public let anonymousRetention: AnonymousRetention
|
|
public let inMemory: Bool
|
|
/// Opt-in-Gate für Cloud-Sync (DSGVO Art. 25, Parität zur Web-App). Ist er
|
|
/// gesetzt, synct die Engine **nur** mit expliziter Nutzer-Einwilligung mit
|
|
/// dem Server: ohne Zustimmung läuft sie lokal-only (anonym, Events stauen
|
|
/// in der Outbox), auch bei vorhandenem Login. Die Einwilligung wird beim
|
|
/// `start()` aus mana-auth `/api/v1/settings` gelesen (gleiche Quelle wie
|
|
/// Web) und per ``EventSyncEngine/setSyncConsent(_:)`` (Konto-Toggle)
|
|
/// umgeschaltet. **Default `false`** → kein Gate, Verhalten wie bisher
|
|
/// (abwärtskompatibel; nicht-adoptierende Apps bleiben unverändert).
|
|
public let requireSyncConsent: Bool
|
|
|
|
public init(
|
|
appId: String,
|
|
syncURL: URL,
|
|
authBaseURL: URL,
|
|
schemaHash: String? = nil,
|
|
storeName: String? = nil,
|
|
appGroupIdentifier: String? = nil,
|
|
additionalModels: [any PersistentModel.Type] = [],
|
|
enableWebSocket: Bool = false,
|
|
enableEncryption: Bool = false,
|
|
scopeResolver: (@Sendable (String) -> String?)? = nil,
|
|
pollInterval: TimeInterval = 60,
|
|
anonymousRetention: AnonymousRetention = .default,
|
|
inMemory: Bool = false,
|
|
requireSyncConsent: Bool = false
|
|
) {
|
|
self.appId = appId
|
|
self.syncURL = syncURL
|
|
self.authBaseURL = authBaseURL
|
|
self.schemaHash = schemaHash
|
|
self.storeName = storeName ?? "\(appId)-event-sync"
|
|
self.appGroupIdentifier = appGroupIdentifier
|
|
self.additionalModels = additionalModels
|
|
self.enableWebSocket = enableWebSocket
|
|
self.enableEncryption = enableEncryption
|
|
self.scopeResolver = scopeResolver
|
|
self.pollInterval = pollInterval
|
|
self.anonymousRetention = anonymousRetention
|
|
self.inMemory = inMemory
|
|
self.requireSyncConsent = requireSyncConsent
|
|
}
|
|
}
|
|
|
|
/// Retention für anonyme (noch nicht in einen Account geliftete) Events.
|
|
/// `0` = unbegrenzt. Default 10.000 Events / 90 Tage (Parität zu Web).
|
|
public struct AnonymousRetention: Sendable, Equatable {
|
|
public let maxEvents: Int
|
|
public let maxAgeDays: Int
|
|
|
|
public init(maxEvents: Int = 10000, maxAgeDays: Int = 90) {
|
|
self.maxEvents = maxEvents
|
|
self.maxAgeDays = maxAgeDays
|
|
}
|
|
|
|
public static let `default` = AnonymousRetention()
|
|
public static let unlimited = AnonymousRetention(maxEvents: 0, maxAgeDays: 0)
|
|
}
|
|
|
|
/// Aktueller Identitäts-Modus der Engine.
|
|
public enum AuthMode: Equatable, Sendable {
|
|
/// Lokal, ohne Konto. `anonId` ist `anon:<id>`; Events stauen in der
|
|
/// Outbox, bis `signIn` sie liftet.
|
|
case anonymous(anonId: String)
|
|
/// Eingeloggt; `userId` ist der JWT-`sub`. Push/Pull/WS aktiv.
|
|
case signedIn(userId: String)
|
|
}
|
|
|
|
/// Ergebnis eines `signIn`-Claims.
|
|
public struct ClaimResult: Sendable, Equatable {
|
|
public let rewrittenEvents: Int
|
|
public let newUserId: String
|
|
}
|
|
|
|
/// Diagnose-Snapshot.
|
|
public struct EngineStats: Sendable, Equatable {
|
|
public let eventsLocal: Int
|
|
public let outboxPending: Int
|
|
public let lastSyncAt: String?
|
|
public let authMode: AuthMode
|
|
/// Persistenter Zähler übersprungener Events, deren Payload beim Pull
|
|
/// nicht entschlüsselt werden konnte. Apps sollten bei > 0 einen
|
|
/// Hinweis zeigen („N Einträge konnten nicht gelesen werden"), statt
|
|
/// den Verlust zu verschweigen.
|
|
public let decryptFailures: Int
|
|
public let decryptFailureLastAt: String?
|
|
}
|
|
|
|
/// Ein Event aus dem Pull, dessen Payload nicht entschlüsselt werden konnte
|
|
/// (falscher Key, beschädigter Ciphertext, oder NoOp-Provider gegen einen
|
|
/// verschlüsselten Log). Das Event wird übersprungen — der Verlust wird aber
|
|
/// persistent gezählt (``EngineStats/decryptFailures``).
|
|
public struct DecryptFailure: Sendable {
|
|
public let eventId: String
|
|
public let aggregateId: String
|
|
public let eventType: String
|
|
public let error: Error
|
|
}
|