mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
feat(credential-pools): surface OAuth expiry + Nous agent-key rotation
auth.json entries now carry expires_at_ms / expires_at and (for Nous) agent_key_obtained_at. Decode the new fields, add an expiryBadge helper, and render a red "expired" / orange "expires in Nd" pill when a credential is past or within 7 days of expiring. Nous entries also get a muted "agent key · Nh ago" line so manual rotations are visibly confirmed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,33 @@ struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
/// OAuth access-token expiry. Populated from `expires_at_ms` (epoch ms,
|
||||
/// preferred) or `expires_at` (ISO8601). Nil for API-key entries and
|
||||
/// for OAuth providers that haven't yet recorded an expiry.
|
||||
let expiresAt: Date?
|
||||
/// When the current Nous agent key was minted — surfaced so users can
|
||||
/// tell whether a recent rotation has gone through. Nil for non-Nous
|
||||
/// providers and for older Nous entries without the field.
|
||||
let agentKeyObtainedAt: Date?
|
||||
|
||||
/// Display-time badge for expiry. Recomputed against `Date()` on each
|
||||
/// render so the label stays current without needing a timer.
|
||||
enum ExpiryBadge: Equatable {
|
||||
case expired
|
||||
case expiringSoon(days: Int)
|
||||
}
|
||||
|
||||
/// Returns a badge when expiry is within 7 days or already past. Nil
|
||||
/// means "not worth flagging" — either expiry is unknown or still far
|
||||
/// enough out that a warning would be noise.
|
||||
func expiryBadge(now: Date = Date()) -> ExpiryBadge? {
|
||||
guard let expiresAt else { return nil }
|
||||
if expiresAt <= now { return .expired }
|
||||
let seconds = expiresAt.timeIntervalSince(now)
|
||||
let days = Int(seconds / 86_400)
|
||||
if days <= 7 { return .expiringSoon(days: max(1, days)) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
@@ -101,7 +128,9 @@ final class CredentialPoolsViewModel {
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
requestCount: entry.request_count ?? 0,
|
||||
expiresAt: Self.resolveExpiry(msField: entry.expires_at_ms, isoField: entry.expires_at),
|
||||
agentKeyObtainedAt: Self.parseISO8601(entry.agent_key_obtained_at)
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
@@ -112,6 +141,30 @@ final class CredentialPoolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer `expires_at_ms` (integer epoch ms — unambiguous) over
|
||||
/// `expires_at` (ISO8601 string). Hermes writes whichever format the
|
||||
/// upstream provider returned; new entries almost always carry the ms
|
||||
/// form, older Nous entries may only have the ISO form.
|
||||
nonisolated private static func resolveExpiry(msField: Double?, isoField: String?) -> Date? {
|
||||
if let ms = msField, ms > 0 {
|
||||
return Date(timeIntervalSince1970: ms / 1000.0)
|
||||
}
|
||||
return parseISO8601(isoField)
|
||||
}
|
||||
|
||||
nonisolated private static func parseISO8601(_ str: String?) -> Date? {
|
||||
guard let s = str, !s.isEmpty else { return nil }
|
||||
// Fractional seconds are present on Nous tokens; plain seconds on
|
||||
// most OAuth providers. Try the fractional parser first, fall back
|
||||
// to the strict one.
|
||||
let withFractional = ISO8601DateFormatter()
|
||||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFractional.date(from: s) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: s)
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
nonisolated private static func tail(of token: String) -> String {
|
||||
@@ -250,9 +303,20 @@ private struct AuthEntry: Decodable, Sendable {
|
||||
nonisolated let access_token: String?
|
||||
nonisolated let last_status: String?
|
||||
nonisolated let request_count: Int?
|
||||
/// Epoch milliseconds. Double (not Int64) because some Nous entries
|
||||
/// round-trip through JS and end up as `1780339200000.0`. Decoding as
|
||||
/// Int would throw on the fractional zero.
|
||||
nonisolated let expires_at_ms: Double?
|
||||
/// ISO8601 — fallback when `expires_at_ms` isn't present.
|
||||
nonisolated let expires_at: String?
|
||||
/// Nous-specific — when the current agent key was issued. Surfaced as
|
||||
/// "Agent key rotated Nh ago" so the user can tell if a recent manual
|
||||
/// rotation has taken effect.
|
||||
nonisolated let agent_key_obtained_at: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, label, auth_type, source, access_token, last_status, request_count
|
||||
case expires_at_ms, expires_at, agent_key_obtained_at
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
@@ -264,5 +328,8 @@ private struct AuthEntry: Decodable, Sendable {
|
||||
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
|
||||
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
|
||||
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
|
||||
self.expires_at_ms = try c.decodeIfPresent(Double.self, forKey: .expires_at_ms)
|
||||
self.expires_at = try c.decodeIfPresent(String.self, forKey: .expires_at)
|
||||
self.agent_key_obtained_at = try c.decodeIfPresent(String.self, forKey: .agent_key_obtained_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ struct CredentialPoolsView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
expiryBadge(cred)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
@@ -150,6 +151,11 @@ struct CredentialPoolsView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if let rotated = cred.agentKeyObtainedAt {
|
||||
Text("agent key · \(Self.relativeAge(rotated))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -179,6 +185,45 @@ struct CredentialPoolsView: View {
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Red "expired" / orange "expires in Nd" pill shown inline with the
|
||||
/// credential's auth-type chip. Hidden when the credential has no
|
||||
/// expiry or is more than 7 days out — no point pulling attention to a
|
||||
/// token the user doesn't need to think about yet.
|
||||
@ViewBuilder
|
||||
private func expiryBadge(_ cred: HermesCredential) -> some View {
|
||||
if let badge = cred.expiryBadge() {
|
||||
switch badge {
|
||||
case .expired:
|
||||
Text("expired")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.red)
|
||||
.clipShape(Capsule())
|
||||
case .expiringSoon(let days):
|
||||
Text("expires in \(days)d")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "2h ago" / "3d ago" / "just now". Kept terse for the one-line
|
||||
/// credential row. `RelativeDateTimeFormatter` isn't used because its
|
||||
/// output ("2 hours ago") is too long for the slot.
|
||||
private static func relativeAge(_ date: Date, now: Date = Date()) -> String {
|
||||
let seconds = Int(now.timeIntervalSince(date))
|
||||
if seconds < 60 { return "just now" }
|
||||
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
||||
if seconds < 86_400 { return "\(seconds / 3600)h ago" }
|
||||
return "\(seconds / 86_400)d ago"
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
|
||||
Reference in New Issue
Block a user