import Foundation public struct FileInstanceRepository: InstanceRepository { private struct Store: Codable { var instances: [Instance] = [] } private let location: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository") public init(location: URL) { self.location = location let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder = encoder let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 self.decoder = decoder } public func list(_ query: InstanceQuery) async throws -> [Instance] { try await run { store in var instances = store.instances if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines), !search.isEmpty { instances = instances.filter { $0.name.localizedCaseInsensitiveContains(search) || $0.endpoint.displayAddress.localizedCaseInsensitiveContains(search) } } if let tags = query.tags, !tags.isEmpty { instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) } } return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } } public func get(_ id: UUID) async throws -> Instance? { try await run { $0.instances.first { $0.id == id } } } public func create(_ input: InstanceInput) async throws -> Instance { try await run { store in guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw RepositoryError.invalidOrigin } let endpoint = try Self.normalized(input.endpoint) guard !store.instances.contains(where: { $0.endpoint.dedupKey == endpoint.dedupKey }) else { throw RepositoryError.duplicateOrigin } let now = Date() let instance = Instance( id: UUID(), name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), endpoint: endpoint, tags: Self.normalizedTags(input.tags), credentialConfigured: !(input.password ?? "").isEmpty, createdAt: now, updatedAt: now ) store.instances.append(instance) return instance } } public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance { try await run { store in guard let index = store.instances.firstIndex(where: { $0.id == id }) else { throw RepositoryError.notFound } guard store.instances[index].configRevision == expectedRevision else { throw RepositoryError.revisionConflict } var instance = store.instances[index] if let name = patch.name { let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin } instance.name = normalized } if let endpoint = patch.endpoint { let normalized = try Self.normalized(endpoint) guard !store.instances.contains(where: { $0.id != id && $0.endpoint.dedupKey == normalized.dedupKey }) else { throw RepositoryError.duplicateOrigin } instance.endpoint = normalized } if let tags = patch.tags { instance.tags = Self.normalizedTags(tags) } if let password = patch.password { instance.credentialConfigured = !password.isEmpty } instance.updatedAt = Date() instance.configRevision += 1 store.instances[index] = instance return instance } } public func delete(_ id: UUID, expectedRevision: Int) async throws { try await run { store in guard let index = store.instances.firstIndex(where: { $0.id == id }) else { throw RepositoryError.notFound } guard store.instances[index].configRevision == expectedRevision else { throw RepositoryError.revisionConflict } store.instances.remove(at: index) } } private func run(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T { try await withCheckedThrowingContinuation { continuation in queue.async { do { var store = try self.readStore() let output = try transform(&store) try self.writeStore(store) continuation.resume(returning: output) } catch { continuation.resume(throwing: error) } } } } private func readStore() throws -> Store { guard FileManager.default.fileExists(atPath: location.path) else { return Store() } do { return try decoder.decode(Store.self, from: Data(contentsOf: location)) } catch { throw RepositoryError.databaseFailed } } private func writeStore(_ store: Store) throws { do { let directory = location.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try encoder.encode(store).write(to: location, options: [.atomic]) } catch { throw RepositoryError.databaseFailed } } private static func normalized(_ endpoint: InstanceEndpoint) throws -> InstanceEndpoint { switch endpoint { case .direct(let url): return .direct(try normalizedOrigin(url)) case .relay(let relay): // A relay endpoint is validated on its own terms: https, a parseable node id, and the // exact key sizes the envelope format requires. A half-configured relay entry that // reached the transport would fail as an unreadable response rather than as "this // pairing is incomplete". guard relay.relayBaseURL.scheme == "https", relay.relayBaseURL.host?.isEmpty == false, relay.relayBaseURL.path.isEmpty || relay.relayBaseURL.path == "/" else { throw RepositoryError.invalidOrigin } guard relay.nodeID.hasPrefix("nd_"), relay.nodeID.count >= 11 else { throw RepositoryError.invalidOrigin } guard relay.agentSigningKey.count == RelayWire.keyBytes, relay.agentKxKey.count == RelayWire.keyBytes, relay.deviceToken.count >= 16, relay.deviceID.hasPrefix("ap_") else { throw RepositoryError.invalidOrigin } var normalized = relay normalized.relayBaseURL = URL(string: relay.relayBaseURL.absoluteString.trimmingCharacters( in: CharacterSet(charactersIn: "/") )) ?? relay.relayBaseURL return .relay(normalized) } } private static func normalizedOrigin(_ url: URL) throws -> URL { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), components.scheme == "http" || components.scheme == "https", let host = components.host, !host.isEmpty else { throw RepositoryError.invalidOrigin } guard components.path.isEmpty || components.path == "/" else { throw RepositoryError.invalidOrigin } var normalized = components normalized.path = "" normalized.query = nil normalized.fragment = nil guard let result = normalized.url else { throw RepositoryError.invalidOrigin } return result } private static func normalizedTags(_ tags: [String]) -> [String] { var seen = Set() return tags .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } } }