mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.
Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.
swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
138 lines
5.1 KiB
Swift
138 lines
5.1 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// The per-hub credential record (`docs/api/client-contract/auth.md`).
|
|
///
|
|
/// The access token is the durable secret; the JWT is a 4-hour cache
|
|
/// persisted only to save one `/api/auth` round-trip at cold start.
|
|
public struct HubCredentials: Codable, Equatable, Sendable {
|
|
/// Normalized hub origin (see ``HubURLNormalization``); also the storage key.
|
|
public var hubUrl: String
|
|
/// Long-lived pairing secret, passed verbatim to `POST /api/auth`.
|
|
public var accessToken: String
|
|
/// Last issued JWT, if any.
|
|
public var jwt: String?
|
|
/// When `jwt` was obtained, epoch ms.
|
|
public var jwtObtainedAt: Int?
|
|
|
|
public init(hubUrl: String, accessToken: String, jwt: String? = nil, jwtObtainedAt: Int? = nil) {
|
|
self.hubUrl = hubUrl
|
|
self.accessToken = accessToken
|
|
self.jwt = jwt
|
|
self.jwtObtainedAt = jwtObtainedAt
|
|
}
|
|
}
|
|
|
|
/// Storage seam so `AuthManager` and the pairing flow are testable without
|
|
/// touching the real Keychain.
|
|
public protocol CredentialStoring: Sendable {
|
|
/// Returns the record for a normalized hub origin, or `nil` when unpaired.
|
|
func credentials(forHub hubUrl: String) throws -> HubCredentials?
|
|
/// Inserts or replaces the record keyed by `credentials.hubUrl`.
|
|
func store(_ credentials: HubCredentials) throws
|
|
/// Removes the record; deleting a non-existent record is not an error.
|
|
func deleteCredentials(forHub hubUrl: String) throws
|
|
}
|
|
|
|
/// A Keychain operation failed with the given `SecItem` status.
|
|
public struct KeychainError: Error, Equatable, Sendable {
|
|
public let status: OSStatus
|
|
|
|
public init(status: OSStatus) {
|
|
self.status = status
|
|
}
|
|
}
|
|
|
|
/// Keychain-backed credential store: one generic-password item per hub under
|
|
/// service `run.hapi.companion`, account = normalized hub origin, value =
|
|
/// JSON-encoded ``HubCredentials``.
|
|
///
|
|
/// Items use `kSecAttrAccessibleAfterFirstUnlock` so a background refresh
|
|
/// after reboot (pre-unlock) fails gracefully instead of silently losing
|
|
/// credentials, and the data-protection keychain so behavior matches across
|
|
/// iOS and macOS (CI).
|
|
public struct KeychainCredentialStore: CredentialStoring {
|
|
public static let service = "run.hapi.companion"
|
|
|
|
public init() {}
|
|
|
|
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
|
|
var query = Self.baseQuery(account: hubUrl)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
switch status {
|
|
case errSecSuccess:
|
|
guard let data = item as? Data else {
|
|
throw KeychainError(status: errSecInternalError)
|
|
}
|
|
return try JSONDecoder().decode(HubCredentials.self, from: data)
|
|
case errSecItemNotFound:
|
|
return nil
|
|
default:
|
|
throw KeychainError(status: status)
|
|
}
|
|
}
|
|
|
|
public func store(_ credentials: HubCredentials) throws {
|
|
let data = try JSONEncoder().encode(credentials)
|
|
let query = Self.baseQuery(account: credentials.hubUrl)
|
|
let update: [String: Any] = [kSecValueData as String: data]
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
if updateStatus == errSecSuccess { return }
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw KeychainError(status: updateStatus)
|
|
}
|
|
var add = query
|
|
add[kSecValueData as String] = data
|
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw KeychainError(status: addStatus)
|
|
}
|
|
}
|
|
|
|
public func deleteCredentials(forHub hubUrl: String) throws {
|
|
let status = SecItemDelete(Self.baseQuery(account: hubUrl) as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw KeychainError(status: status)
|
|
}
|
|
}
|
|
|
|
private static func baseQuery(account: String) -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecUseDataProtectionKeychain as String: true,
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Dictionary-backed test double (also handy for previews).
|
|
public final class InMemoryCredentialStore: CredentialStoring, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var records: [String: HubCredentials] = [:]
|
|
|
|
public init() {}
|
|
|
|
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return records[hubUrl]
|
|
}
|
|
|
|
public func store(_ credentials: HubCredentials) throws {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
records[credentials.hubUrl] = credentials
|
|
}
|
|
|
|
public func deleteCredentials(forHub hubUrl: String) throws {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
records[hubUrl] = nil
|
|
}
|
|
}
|