import Foundation import CoreFoundation import CryptoKit struct AgentPushConfig: Codable, Sendable { var version: Int var enabled: Bool var apnsAvailable: Bool var webPushAvailable: Bool var sourceId: String? var quarantined: Bool var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true } } enum AgentAPNSEnvironment: String, Codable, Sendable { case sandbox, production // The app's Info.plist and entitlement expand the SAME XcodeGen build setting. // Never guess from DEBUG, token length, simulator, or marketing version. init?(entitlementSetting: String?) { switch entitlementSetting { case "development": self = .sandbox case "production": self = .production default: return nil } } } struct AgentPushRegistration: Encodable, Equatable, Sendable { let transport = "apns" let enabled = true var token: String var environment: AgentAPNSEnvironment let topic = "net.buzzert.sybil2" } struct AgentPushReceipt: Codable, Sendable { var installationId: String var transport: String var enabled: Bool var version: Int } protocol AgentPushAPIClienting: Sendable { func agentPushConfig() async throws -> AgentPushConfig func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt func agentRevokePush(id: UUID) async throws func agentReadState() async throws -> AgentReadState } struct AgentPushRequestError: LocalizedError, Sendable { var status: Int var retryable: Bool { status == 408 || status == 429 || status >= 500 } var errorDescription: String? { "Agent notification request failed (\(status))." } } struct AgentPushScope: Codable, Equatable, Hashable, Sendable { var backend: String // ADMIN_TOKEN identifies one shared personal principal, not individual people. var owner = "shared-personal-admin" var credentialScope: String var sourceID: String static func credentialScope(backend: String, token: String) -> String { // Nonsecret consent epoch, never logged. A credential replacement requires // new consent even when the server still represents one personal principal. SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8)) .map { String(format: "%02x", $0) }.joined() } static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? { guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false), let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil } parts.scheme = parts.scheme?.lowercased(); parts.host = host if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil } let path = parts.path guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil } parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) return parts.url?.absoluteString } } struct AgentPushPayload: Equatable, Sendable { enum Kind: String { case message, readState = "read_state" } var sourceID: String var kind: Kind var cursor: String var messageID: String? init?(_ info: [AnyHashable: Any]) { guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1, let source = info["sourceId"] as? String, Self.validSource(source), let name = info["kind"] as? String, let kind = Kind(rawValue: name), let raw = info[kind == .message ? "cursor" : "readCursor"] as? String, let cursor = Self.validCursor(raw) else { return nil } let message = info["messageId"] as? String if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil } self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message } static func validSource(_ value: String) -> Bool { value.utf8.count == 36 && UUID(uuidString: value) != nil } static func validCursor(_ value: String) -> String? { guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value), !AgentCursor.less("9007199254740991", normalized) else { return nil } return normalized } } struct AgentDeliveredNotification: Sendable { var identifier: String // UNNotification.request.identifier, never messageId. var payload: AgentPushPayload? } enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional } @MainActor protocol AgentNotificationPlatform: AnyObject { var environment: AgentAPNSEnvironment? { get } var allowsLoopback: Bool { get } func permission() async -> AgentNotificationPermission func requestPermission() async throws -> Bool func register(generation: UUID) func unregister() func delivered() async -> [AgentDeliveredNotification] func removeDelivered(identifiers: [String]) } struct AgentPushRecord: Codable, Equatable { var scope: AgentPushScope var installationID = UUID() var optedIn = false var mayBeRegistered = false var version: Int? } struct AgentPushPersistence: Codable { var records: [AgentPushRecord] = [] var tombstones: [AgentPushRecord] = [] } @MainActor struct AgentPushStorage { var read: () throws -> AgentPushPersistence var write: (AgentPushPersistence) throws -> Void static func defaults(_ defaults: UserDefaults = .standard) -> Self { let key = "sybil.ios.agentPush.v1" return Self(read: { guard let data = defaults.data(forKey: key) else { return .init() } return try JSONDecoder().decode(AgentPushPersistence.self, from: data) }, write: { value in // No authentication credential or device token is ever persisted here. defaults.set(try JSONEncoder().encode(value), forKey: key) }) } }