Files
2026-07-13 12:22:33 +08:00

333 lines
13 KiB
Swift

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]
}
}
}