import Foundation
/// A client-side safety net for agents and automation: domain-touching mutations
/// (POST/PUT/PATCH/DELETE) are refused before any request when the domain is on the
/// deny-list or missing from the allow-list. Reads are never gated.
public struct GateRules: Sendable, Equatable {
/// Exact names or `*.suffix` patterns that may never be touched. Deny wins.
public var deniedDomains: [String]?
/// When set, only matching domains may be touched; `[]` allows nothing, nil means no allow-list.
public var allowedDomains: [String]?
public init(deniedDomains: [String]? = nil, allowedDomains: [String]? = nil) {
self.deniedDomains = deniedDomains
self.allowedDomains = allowedDomains
}
/// The denial for `domain`, or nil when the mutation may proceed. An unresolvable
/// domain passes under a pure deny-list and fails closed under an allow-list.
public func decide(_ domain: String?) -> GateDenial? {
guard let name = normalizeDomain(domain) else {
return allowedDomains != nil ? GateDenial(domain: nil, pattern: nil) : nil
}
if let denied = deniedDomains?.first(where: { patternMatches($0, name) }) {
return GateDenial(domain: name, pattern: denied)
}
if let allowed = allowedDomains, !allowed.contains(where: { patternMatches($0, name) }) {
return GateDenial(domain: name, pattern: nil)
}
return nil
}
}
/// What a mutation touches: a domain name, or a registration whose domain the gate resolves.
public enum GateTarget: Sendable, Equatable {
case domain(String?)
case registrationId(String?)
}
public struct GateDenial: Sendable, Equatable {
public let domain: String?
/// The deny-list pattern that matched; nil for an allow-list miss.
public let pattern: String?
}
/// Lower-case, trimmed, without trailing dots; nil for blank input.
public func normalizeDomain(_ domain: String?) -> String? {
guard let domain else { return nil }
var name = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
while name.hasSuffix(".") { name.removeLast() }
return name.isEmpty ? nil : name
}
/// `*.example.ro` matches `example.ro` and every name under it; anything else is exact.
func patternMatches(_ pattern: String, _ name: String) -> Bool {
guard let normalized = normalizeDomain(pattern) else { return false }
if normalized.hasPrefix("*.") {
let suffix = String(normalized.dropFirst(2))
return name == suffix || name.hasSuffix("." + suffix)
}
return normalized == name
}