mana-swift-event-sync/Sources/ManaEventSync/Engine/EventSyncConfig.swift
Till JS e574bc55d6 Cloud-Sync-Opt-in-Gate (Art. 25) — Parität zur Web-App
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>
2026-06-23 16:09:25 +02:00

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
}