import Foundation public struct DeviceAuthEntry: Codable, Sendable { public let token: String public let role: String public let scopes: [String] public let updatedAtMs: Int public init(token: String, role: String, scopes: [String], updatedAtMs: Int) { self.token = token self.role = role self.scopes = scopes self.updatedAtMs = updatedAtMs } } private struct DeviceAuthStoreFile: Codable { var version: Int var deviceId: String var tokens: [String: DeviceAuthEntry] } public enum DeviceAuthStore { private static let fileName = "device-auth.json" public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { guard let store = readStore(), store.deviceId == deviceId else { return nil } let role = normalizeRole(role) return store.tokens[role] } public static func storeToken( deviceId: String, role: String, token: String, scopes: [String] = [] ) -> DeviceAuthEntry { let normalizedRole = normalizeRole(role) var next = readStore() if next?.deviceId != deviceId { next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) } let entry = DeviceAuthEntry( token: token, role: normalizedRole, scopes: normalizeScopes(scopes), updatedAtMs: Int(Date().timeIntervalSince1970 * 1000) ) if next == nil { next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) } next?.tokens[normalizedRole] = entry if let store = next { writeStore(store) } return entry } public static func clearToken(deviceId: String, role: String) { guard var store = readStore(), store.deviceId == deviceId else { return } let normalizedRole = normalizeRole(role) guard store.tokens[normalizedRole] != nil else { return } store.tokens.removeValue(forKey: normalizedRole) writeStore(store) } private static func normalizeRole(_ role: String) -> String { role.trimmingCharacters(in: .whitespacesAndNewlines) } private static func normalizeScopes(_ scopes: [String]) -> [String] { let trimmed = scopes .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return Array(Set(trimmed)).sorted() } private static func fileURL() -> URL { DeviceIdentityPaths.stateDirURL() .appendingPathComponent("identity", isDirectory: true) .appendingPathComponent(fileName, isDirectory: false) } private static func readStore() -> DeviceAuthStoreFile? { let url = fileURL() guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } return decoded } private static func writeStore(_ store: DeviceAuthStoreFile) { let url = fileURL() do { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try JSONEncoder().encode(store) try data.write(to: url, options: [.atomic]) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } catch { // best-effort only } } }