332 lines · 12.6 KB · Swift
RawDownload
import Foundation public enum CodexDashboardSourceKind: String, Codable, Sendable {    case liveWeb    case cachedDashboard} public enum CodexDashboardDisposition: String, Codable, Sendable {    case attach    case displayOnly    case failClosed} public enum CodexDashboardAllowedEffect: String, Codable, CaseIterable, Hashable, Sendable {    case usageBackfill    case creditsAttachment    case refreshGuardSeed    case historicalBackfill    case cachedDashboardReuse} public enum CodexDashboardCleanup: String, Codable, CaseIterable, Hashable, Sendable {    case dashboardSnapshot    case dashboardDerivedUsage    case dashboardDerivedCredits    case dashboardRefreshGuardSeed    case dashboardCache} public struct CodexDashboardKnownOwnerCandidate: Equatable, Hashable, Sendable {    public let identity: CodexIdentity    public let normalizedEmail: String?    public let sourceIsolationIdentifier: String?     public init(        identity: CodexIdentity,        normalizedEmail: String?,        sourceIsolationIdentifier: String? = nil)    {        self.identity = identity        self.normalizedEmail = normalizedEmail        self.sourceIsolationIdentifier = sourceIsolationIdentifier    }     public func hash(into hasher: inout Hasher) {        switch self.identity {        case let .providerAccount(id):            hasher.combine("providerAccount")            hasher.combine(id)        case let .emailOnly(normalizedEmail):            hasher.combine("emailOnly")            hasher.combine(normalizedEmail)        case .unresolved:            hasher.combine("unresolved")        }        hasher.combine(self.normalizedEmail)        hasher.combine(self.sourceIsolationIdentifier)    }} public struct CodexDashboardOwnershipProofContext: Equatable, Sendable {    public let currentIdentity: CodexIdentity    public let expectedScopedEmail: String?    public let trustedCurrentUsageEmail: String?    public let dashboardSignedInEmail: String?    public let knownOwners: [CodexDashboardKnownOwnerCandidate]     public init(        currentIdentity: CodexIdentity,        expectedScopedEmail: String?,        trustedCurrentUsageEmail: String?,        dashboardSignedInEmail: String?,        knownOwners: [CodexDashboardKnownOwnerCandidate])    {        self.currentIdentity = currentIdentity        self.expectedScopedEmail = expectedScopedEmail        self.trustedCurrentUsageEmail = trustedCurrentUsageEmail        self.dashboardSignedInEmail = dashboardSignedInEmail        self.knownOwners = knownOwners    }} public struct CodexDashboardRoutingHints: Equatable, Sendable {    public let targetEmail: String?    public let lastKnownDashboardRoutingEmail: String?     public init(targetEmail: String?, lastKnownDashboardRoutingEmail: String?) {        self.targetEmail = targetEmail        self.lastKnownDashboardRoutingEmail = lastKnownDashboardRoutingEmail    }} public struct CodexDashboardAuthorityInput: Equatable, Sendable {    public let sourceKind: CodexDashboardSourceKind    public let proof: CodexDashboardOwnershipProofContext    public let routing: CodexDashboardRoutingHints     public init(        sourceKind: CodexDashboardSourceKind,        proof: CodexDashboardOwnershipProofContext,        routing: CodexDashboardRoutingHints)    {        self.sourceKind = sourceKind        self.proof = proof        self.routing = routing    }} public enum CodexDashboardDecisionReason: Equatable, Sendable {    case exactProviderAccountMatch    case trustedEmailMatchNoCompetingOwner    case trustedContinuityNoCompetingOwner    case wrongEmail(expected: String?, actual: String?)    case sameEmailAmbiguity(email: String)    case unresolvedWithoutTrustedEvidence    case providerAccountMissingScopedEmail    case providerAccountLacksExactOwnershipProof    case missingDashboardSignedInEmail} public struct CodexDashboardAuthorityDecision: Equatable, Sendable {    public let disposition: CodexDashboardDisposition    public let reason: CodexDashboardDecisionReason    public let allowedEffects: Set<CodexDashboardAllowedEffect>    public let cleanup: Set<CodexDashboardCleanup>     public init(        disposition: CodexDashboardDisposition,        reason: CodexDashboardDecisionReason,        allowedEffects: Set<CodexDashboardAllowedEffect>,        cleanup: Set<CodexDashboardCleanup>)    {        self.disposition = disposition        self.reason = reason        self.allowedEffects = allowedEffects        self.cleanup = cleanup    }} public enum CodexDashboardPolicyError: LocalizedError, Equatable, Sendable {    case displayOnly(CodexDashboardAuthorityDecision)     public var errorDescription: String? {        switch self {        case .displayOnly:            "Codex dashboard may be displayed, but it cannot be attached to the active account."        }    }} public enum CodexDashboardAuthority {    /// Evaluates whether a Codex dashboard snapshot may attach to the active account.    ///    /// App callers may keep `.displayOnly` dashboard data visible, but must not attach usage,    /// credits, refresh guards, or historical backfill.    ///    /// CLI callers should surface `.displayOnly` as `CodexDashboardPolicyError.displayOnly`    /// instead of treating it as a generic failure.    ///    /// Cached dashboard callers may restore data only when the decision includes    /// `.cachedDashboardReuse` in `allowedEffects`.    public static func evaluate(_ input: CodexDashboardAuthorityInput) -> CodexDashboardAuthorityDecision {        let proof = input.proof        let currentIdentity = Self.normalizeIdentity(proof.currentIdentity)        let expectedScopedEmail = CodexIdentityResolver.normalizeEmail(proof.expectedScopedEmail)        let trustedCurrentUsageEmail = CodexIdentityResolver.normalizeEmail(proof.trustedCurrentUsageEmail)        let dashboardSignedInEmail = CodexIdentityResolver.normalizeEmail(proof.dashboardSignedInEmail)        let knownOwners = Self.normalizeKnownOwners(proof.knownOwners)         // Routing hints are intentionally excluded from ownership proof. They may help fetch or route        // dashboard requests, but they must never influence attach/display/fail-closed policy.         guard let dashboardSignedInEmail else {            return Self.makeDecision(                disposition: .failClosed,                reason: .missingDashboardSignedInEmail,                sourceKind: input.sourceKind)        }         if let expectedScopedEmail, dashboardSignedInEmail != expectedScopedEmail {            return Self.makeDecision(                disposition: .failClosed,                reason: .wrongEmail(expected: expectedScopedEmail, actual: dashboardSignedInEmail),                sourceKind: input.sourceKind)        }         switch currentIdentity {        case let .providerAccount(id):            guard expectedScopedEmail != nil else {                return Self.makeDecision(                    disposition: .failClosed,                    reason: .providerAccountMissingScopedEmail,                    sourceKind: input.sourceKind)            }            if Self.knownOwnerCount(for: dashboardSignedInEmail, in: knownOwners) > 1 {                return Self.makeDecision(                    disposition: .displayOnly,                    reason: .sameEmailAmbiguity(email: dashboardSignedInEmail),                    sourceKind: input.sourceKind)            }            let exactMatch = knownOwners.contains { candidate in                candidate.identity == .providerAccount(id: id) && candidate.normalizedEmail == dashboardSignedInEmail            }            if exactMatch {                return Self.makeDecision(                    disposition: .attach,                    reason: .exactProviderAccountMatch,                    sourceKind: input.sourceKind)            }            return Self.makeDecision(                disposition: .failClosed,                reason: .providerAccountLacksExactOwnershipProof,                sourceKind: input.sourceKind)         case let .emailOnly(normalizedEmail):            guard dashboardSignedInEmail == normalizedEmail else {                return Self.makeDecision(                    disposition: .failClosed,                    reason: .wrongEmail(expected: normalizedEmail, actual: dashboardSignedInEmail),                    sourceKind: input.sourceKind)            }            if Self.knownOwnerCount(for: normalizedEmail, in: knownOwners) > 1 {                return Self.makeDecision(                    disposition: .displayOnly,                    reason: .sameEmailAmbiguity(email: normalizedEmail),                    sourceKind: input.sourceKind)            }            return Self.makeDecision(                disposition: .attach,                reason: .trustedEmailMatchNoCompetingOwner,                sourceKind: input.sourceKind)         case .unresolved:            guard let trustedCurrentUsageEmail else {                return Self.makeDecision(                    disposition: .failClosed,                    reason: .unresolvedWithoutTrustedEvidence,                    sourceKind: input.sourceKind)            }            guard dashboardSignedInEmail == trustedCurrentUsageEmail else {                return Self.makeDecision(                    disposition: .failClosed,                    reason: .wrongEmail(expected: trustedCurrentUsageEmail, actual: dashboardSignedInEmail),                    sourceKind: input.sourceKind)            }            if Self.knownOwnerCount(for: trustedCurrentUsageEmail, in: knownOwners) > 1 {                return Self.makeDecision(                    disposition: .displayOnly,                    reason: .sameEmailAmbiguity(email: trustedCurrentUsageEmail),                    sourceKind: input.sourceKind)            }            return Self.makeDecision(                disposition: .attach,                reason: .trustedContinuityNoCompetingOwner,                sourceKind: input.sourceKind)        }    }     private static func normalizeIdentity(_ identity: CodexIdentity) -> CodexIdentity {        switch identity {        case let .providerAccount(id):            if let normalizedID = CodexIdentityResolver.normalizeAccountID(id) {                return .providerAccount(id: normalizedID)            }            return .unresolved        case let .emailOnly(normalizedEmail):            if let normalizedEmail = CodexIdentityResolver.normalizeEmail(normalizedEmail) {                return .emailOnly(normalizedEmail: normalizedEmail)            }            return .unresolved        case .unresolved:            return .unresolved        }    }     private static func normalizeKnownOwners(        _ candidates: [CodexDashboardKnownOwnerCandidate])        -> Set<CodexDashboardKnownOwnerCandidate>    {        Set(candidates.map { candidate in            let identity = self.normalizeIdentity(candidate.identity)            let sourceIsolationIdentifier: String? = switch identity {            case .providerAccount:                nil            case .emailOnly, .unresolved:                candidate.sourceIsolationIdentifier            }            return CodexDashboardKnownOwnerCandidate(                identity: identity,                normalizedEmail: CodexIdentityResolver.normalizeEmail(candidate.normalizedEmail),                sourceIsolationIdentifier: sourceIsolationIdentifier)        })    }     private static func knownOwnerCount(        for email: String,        in candidates: Set<CodexDashboardKnownOwnerCandidate>) -> Int    {        candidates.count { $0.normalizedEmail == email }    }     private static func makeDecision(        disposition: CodexDashboardDisposition,        reason: CodexDashboardDecisionReason,        sourceKind: CodexDashboardSourceKind) -> CodexDashboardAuthorityDecision    {        CodexDashboardAuthorityDecision(            disposition: disposition,            reason: reason,            allowedEffects: self.allowedEffects(disposition: disposition, sourceKind: sourceKind),            cleanup: disposition == .attach ? [] : Set(CodexDashboardCleanup.allCases))    }     private static func allowedEffects(        disposition: CodexDashboardDisposition,        sourceKind: CodexDashboardSourceKind) -> Set<CodexDashboardAllowedEffect>    {        guard disposition == .attach else { return [] }         switch sourceKind {        case .liveWeb:            return [                .usageBackfill,                .creditsAttachment,                .refreshGuardSeed,                .historicalBackfill,            ]        case .cachedDashboard:            return [.cachedDashboardReuse]        }    }}