mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +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.
77 lines
2.9 KiB
Swift
77 lines
2.9 KiB
Swift
import Foundation
|
|
|
|
/// Decoded payload of a hub JWT.
|
|
///
|
|
/// The hub signs `{uid, ns}` plus standard `iat`/`exp` (HS256, 4 h expiry —
|
|
/// `docs/api/client-contract/auth.md`). Clients treat the token as opaque for
|
|
/// auth purposes but may read the payload for proactive refresh scheduling
|
|
/// and namespace gating (`ns == "default"` is the hub owner).
|
|
public struct JWTClaims: Equatable, Sendable {
|
|
public let uid: Int
|
|
/// Namespace the token operates in (`"default"` = hub owner).
|
|
public let ns: String
|
|
/// Expiry, seconds since the Unix epoch; `nil` when the payload has none.
|
|
public let exp: Int?
|
|
|
|
public init(uid: Int, ns: String, exp: Int? = nil) {
|
|
self.uid = uid
|
|
self.ns = ns
|
|
self.exp = exp
|
|
}
|
|
|
|
public var expiresAt: Date? {
|
|
exp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
|
|
}
|
|
}
|
|
|
|
/// Payload-only JWT reader. No signature verification — only the hub can
|
|
/// verify HS256 tokens, and the client merely schedules around `exp`.
|
|
public enum JWT {
|
|
/// Decodes the base64url middle segment of `token` into ``JWTClaims``.
|
|
/// Returns `nil` for anything that is not a well-formed hub JWT payload
|
|
/// (wrong segment count, invalid base64url, non-JSON, missing `uid`/`ns`).
|
|
public static func claims(from token: String) -> JWTClaims? {
|
|
// `components(separatedBy:)` keeps empty segments, so "a..b" cannot
|
|
// shift the payload position the way `split` would.
|
|
let segments = token.components(separatedBy: ".")
|
|
guard segments.count >= 2 else { return nil }
|
|
guard let payload = decodeBase64URLSegment(segments[1]) else { return nil }
|
|
|
|
struct RawClaims: Decodable {
|
|
let uid: Int
|
|
let ns: String
|
|
let exp: Double?
|
|
}
|
|
guard let raw = try? JSONDecoder().decode(RawClaims.self, from: payload) else { return nil }
|
|
return JWTClaims(uid: raw.uid, ns: raw.ns, exp: raw.exp.flatMap(safeInt))
|
|
}
|
|
|
|
/// base64url → `Data`: translate the URL-safe alphabet and restore the
|
|
/// stripped `=` padding. A length of 1 (mod 4) is not valid base64.
|
|
static func decodeBase64URLSegment(_ segment: String) -> Data? {
|
|
var base64 = segment
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
switch base64.count % 4 {
|
|
case 0:
|
|
break
|
|
case 2:
|
|
base64 += "=="
|
|
case 3:
|
|
base64 += "="
|
|
default:
|
|
return nil
|
|
}
|
|
return Data(base64Encoded: base64)
|
|
}
|
|
|
|
/// `Int(_:)` traps on out-of-range doubles; a hostile token must not
|
|
/// crash the app.
|
|
private static func safeInt(_ value: Double) -> Int? {
|
|
guard value.isFinite,
|
|
value >= -9_000_000_000_000_000_000,
|
|
value <= 9_000_000_000_000_000_000 else { return nil }
|
|
return Int(value)
|
|
}
|
|
}
|