Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -14,18 +14,25 @@ public actor GatewayGrantCoordinator {
|
||||
private let store: any GatewayGrantCredentialStore
|
||||
private let session: URLSession
|
||||
private let now: @Sendable () -> Date
|
||||
private let refreshPath: String
|
||||
private let accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing
|
||||
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
|
||||
|
||||
public init(
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
|
||||
session: URLSession = .shared,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
refreshPath: String = "v1/gateway/grants/refresh",
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing =
|
||||
AppGroupManagedGatewayAccountAccessPolicy()
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.store = store
|
||||
self.session = session
|
||||
self.refreshPath = refreshPath
|
||||
self.now = now
|
||||
self.accountAccessPolicy = accountAccessPolicy
|
||||
}
|
||||
|
||||
/// Host-only integration point. The account access token authorizes grant
|
||||
@@ -73,6 +80,12 @@ public actor GatewayGrantCoordinator {
|
||||
for scope: ManagedGatewayCapability,
|
||||
forceRefresh: Bool = false
|
||||
) async throws -> String {
|
||||
// A valid cached grant is not proof of a current account session.
|
||||
// Check this before loading or refreshing credentials so signed-out
|
||||
// callers cannot consume credits through stale Keychain state.
|
||||
guard accountAccessPolicy.allowsAccountManagedAccess() else {
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
guard let credentials = try await store.load() else {
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
@@ -123,7 +136,7 @@ public actor GatewayGrantCoordinator {
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
|
||||
var request = URLRequest(url: endpoint(refreshPath))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(
|
||||
@@ -230,6 +243,8 @@ enum ManagedGatewayHTTP {
|
||||
switch code.lowercased() {
|
||||
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
|
||||
return .insufficientCredits
|
||||
case "oobe_feature_already_used":
|
||||
return .oobeFeatureAlreadyUsed
|
||||
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
|
||||
return .invalidGrant
|
||||
default:
|
||||
|
||||
@@ -14,18 +14,25 @@ public protocol GatewayGrantCredentialStore: Sendable {
|
||||
}
|
||||
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
|
||||
public enum Slot: String, Sendable {
|
||||
case account = "scope-limited.active"
|
||||
case oobe = "scope-limited.oobe"
|
||||
}
|
||||
|
||||
public enum StoreError: Error, Equatable, Sendable {
|
||||
case unexpectedStatus(OSStatus)
|
||||
case invalidStoredValue
|
||||
}
|
||||
|
||||
private static let service = "com.osgkeyboard.gateway-grant"
|
||||
private static let account = "scope-limited.active"
|
||||
private let slot: Slot
|
||||
|
||||
public init() {}
|
||||
public init(slot: Slot = .account) {
|
||||
self.slot = slot
|
||||
}
|
||||
|
||||
public func load() async throws -> ManagedGatewayGrantCredentials? {
|
||||
var query = Self.baseQuery
|
||||
var query = baseQuery
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
@@ -51,14 +58,14 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
let data = try Self.encoder.encode(credentials)
|
||||
let updateStatus = SecItemUpdate(
|
||||
Self.baseQuery as CFDictionary,
|
||||
baseQuery as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
var query = Self.baseQuery
|
||||
var query = baseQuery
|
||||
query[kSecValueData as String] = data
|
||||
// The extension can refresh after reboot without making account
|
||||
// credentials readable; this item contains only the limited grant.
|
||||
@@ -73,17 +80,17 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
let status = SecItemDelete(Self.baseQuery as CFDictionary)
|
||||
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw StoreError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
private static var baseQuery: [String: Any] {
|
||||
private var baseQuery: [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: slot.rawValue,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
||||
]
|
||||
#if os(macOS)
|
||||
@@ -104,3 +111,23 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
/// Dedicated extension-readable slot for anonymous onboarding grants. It can
|
||||
/// never overwrite or load the signed-in account's normal managed grant.
|
||||
public struct OOBEGatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
|
||||
private let storage = GatewayGrantKeychainStore(slot: .oobe)
|
||||
|
||||
public init() {}
|
||||
|
||||
public func load() async throws -> ManagedGatewayGrantCredentials? {
|
||||
try await storage.load()
|
||||
}
|
||||
|
||||
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
try await storage.save(credentials)
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
try await storage.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// ManagedGatewayAccountAccessPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Device-local account-session gate for account-funded managed requests.
|
||||
// The marker is intentionally non-secret and is never part of iCloud settings.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ManagedGatewayAccountAccessAuthorizing: Sendable {
|
||||
func allowsAccountManagedAccess() -> Bool
|
||||
}
|
||||
|
||||
/// Production policy shared by the host and keyboard extension. A cached grant
|
||||
/// alone is insufficient: the host must also have confirmed an account session.
|
||||
public struct AppGroupManagedGatewayAccountAccessPolicy:
|
||||
ManagedGatewayAccountAccessAuthorizing,
|
||||
@unchecked Sendable {
|
||||
private let defaults: UserDefaults?
|
||||
|
||||
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func allowsAccountManagedAccess() -> Bool {
|
||||
defaults?.bool(
|
||||
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
|
||||
) == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Anonymous OOBE grants have their own bounded practice-session policy and do
|
||||
/// not represent account-funded access. Tests may also inject this explicitly.
|
||||
public struct UnrestrictedManagedGatewayAccountAccessPolicy:
|
||||
ManagedGatewayAccountAccessAuthorizing {
|
||||
public init() {}
|
||||
|
||||
public func allowsAccountManagedAccess() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
|
||||
case translation
|
||||
case editLastInput = "edit_last_input"
|
||||
case aiQuestion = "ai_question"
|
||||
case currentInformationQuestion = "current_information_question"
|
||||
case clipboardTransform = "clipboard_transform"
|
||||
case customSkill = "custom_skill"
|
||||
case agentPlanning = "agent_planning"
|
||||
@@ -30,6 +31,25 @@ public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
||||
case oobe
|
||||
}
|
||||
|
||||
/// Server-audited onboarding capability. This value is carried independently
|
||||
/// from `taskKind` so billing and abuse policy never infer OOBE eligibility
|
||||
/// from a generic clipboard or AI operation.
|
||||
public enum ManagedGatewayOOBEFeature: String, Codable, CaseIterable, Sendable {
|
||||
case voiceInput = "voice_input"
|
||||
case clipboardTranslate = "clipboard_translate"
|
||||
case clipboardReply = "clipboard_reply"
|
||||
case askAI = "ask_ai"
|
||||
|
||||
public var requiredCapability: ManagedGatewayCapability {
|
||||
switch self {
|
||||
case .voiceInput:
|
||||
return .polish
|
||||
case .clipboardTranslate, .clipboardReply, .askAI:
|
||||
return .assistant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
||||
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
||||
|
||||
@@ -85,6 +105,7 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
case scopeNotGranted(ManagedGatewayCapability)
|
||||
case invalidGrant
|
||||
case insufficientCredits
|
||||
case oobeFeatureAlreadyUsed
|
||||
case timeout
|
||||
case server(code: String, status: Int, requestId: String?)
|
||||
|
||||
@@ -102,6 +123,8 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
return SharedL10n.string("managed.error.grantRejected")
|
||||
case .insufficientCredits:
|
||||
return SharedL10n.string("managed.error.insufficientCredits")
|
||||
case .oobeFeatureAlreadyUsed:
|
||||
return SharedL10n.string("managed.error.oobeFeatureAlreadyUsed")
|
||||
case .timeout:
|
||||
return SharedL10n.string("managed.error.timeout")
|
||||
case .server(let code, let status, _):
|
||||
@@ -115,15 +138,15 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagedGatewayGrantTokenResponse: Decodable {
|
||||
let grantId: String
|
||||
let scopes: Set<ManagedGatewayCapability>
|
||||
let accessToken: String
|
||||
let accessExpiresAt: Date
|
||||
let refreshToken: String
|
||||
let refreshExpiresAt: Date
|
||||
public struct ManagedGatewayGrantTokenResponse: Decodable, Sendable {
|
||||
public let grantId: String
|
||||
public let scopes: Set<ManagedGatewayCapability>
|
||||
public let accessToken: String
|
||||
public let accessExpiresAt: Date
|
||||
public let refreshToken: String
|
||||
public let refreshExpiresAt: Date
|
||||
|
||||
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
||||
public func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
||||
ManagedGatewayGrantCredentials(
|
||||
grantId: grantId,
|
||||
scopes: scopes,
|
||||
@@ -150,4 +173,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
|
||||
let stream: Bool
|
||||
let taskKind: ManagedGatewayTaskKind
|
||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
let oobeFeature: ManagedGatewayOOBEFeature?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// ManagedGatewayQuestionRouter.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Deterministic, on-device routing for questions that cannot be answered
|
||||
// reliably without current information. No prompt content is persisted.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ManagedGatewayQuestionRouter {
|
||||
public static func taskKind(
|
||||
for question: String,
|
||||
requestedTaskKind: ManagedGatewayTaskKind = .aiQuestion
|
||||
) -> ManagedGatewayTaskKind {
|
||||
guard requestedTaskKind == .aiQuestion else { return requestedTaskKind }
|
||||
return requiresCurrentInformation(question)
|
||||
? .currentInformationQuestion
|
||||
: .aiQuestion
|
||||
}
|
||||
|
||||
public static func requiresCurrentInformation(_ question: String) -> Bool {
|
||||
let normalized = question
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return false }
|
||||
|
||||
if strongCurrentSignals.contains(where: normalized.contains) {
|
||||
return true
|
||||
}
|
||||
let hasTemporalSignal = temporalSignals.contains(where: normalized.contains)
|
||||
let hasCurrentSubject = currentSubjects.contains(where: normalized.contains)
|
||||
return hasTemporalSignal && hasCurrentSubject
|
||||
}
|
||||
|
||||
private static let strongCurrentSignals = [
|
||||
"最新", "实时", "热点", "头条", "热搜", "要闻", "路况",
|
||||
"breaking news", "latest news", "current events", "live score",
|
||||
"stock price", "exchange rate", "traffic conditions"
|
||||
]
|
||||
|
||||
private static let temporalSignals = [
|
||||
"昨天", "今天", "今日", "今晚", "明天", "后天", "现在", "当前",
|
||||
"此刻", "刚刚", "最近", "本周", "本周末", "本月", "今年",
|
||||
"yesterday", "today", "tonight", "tomorrow", "right now",
|
||||
"currently", "recent", "this week", "this weekend", "this month", "this year"
|
||||
]
|
||||
|
||||
private static let currentSubjects = [
|
||||
"新闻", "天气", "气温", "下雨", "台风", "股价", "股票", "大盘",
|
||||
"汇率", "价格", "票价",
|
||||
"比分", "赛果", "排名", "航班", "油价", "金价", "发生", "大事",
|
||||
"news", "weather", "temperature", "price", "score", "ranking",
|
||||
"flight", "forecast", "what happened"
|
||||
]
|
||||
}
|
||||
@@ -51,10 +51,12 @@ public struct ManagedLLMClient: LLMClient {
|
||||
public let capability: Capability
|
||||
public let taskKind: ManagedGatewayTaskKind
|
||||
public let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
public let oobeFeature: ManagedGatewayOOBEFeature?
|
||||
public let requestTimeout: TimeInterval
|
||||
|
||||
private let baseURL: URL
|
||||
private let grants: GatewayGrantCoordinator
|
||||
private let oobeGrants: OOBEGatewayGrantCoordinator
|
||||
private let session: URLSession
|
||||
private let requestId: @Sendable () -> String
|
||||
|
||||
@@ -62,7 +64,9 @@ public struct ManagedLLMClient: LLMClient {
|
||||
capability: Capability,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
oobeGrants: OOBEGatewayGrantCoordinator? = nil,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared,
|
||||
requestTimeout: TimeInterval = 15,
|
||||
@@ -71,7 +75,12 @@ public struct ManagedLLMClient: LLMClient {
|
||||
self.capability = capability
|
||||
self.taskKind = taskKind ?? capability.defaultTaskKind
|
||||
self.requestPurpose = requestPurpose
|
||||
self.oobeFeature = oobeFeature
|
||||
self.grants = grants
|
||||
self.oobeGrants = oobeGrants ?? OOBEGatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
)
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.requestTimeout = requestTimeout
|
||||
@@ -189,7 +198,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
do {
|
||||
return try await bufferedAttempt(attempt.forcingRefresh())
|
||||
} catch ManagedGatewayError.invalidGrant {
|
||||
try? await grants.clearGrant()
|
||||
try? await clearSelectedGrant()
|
||||
throw ManagedGatewayError.invalidGrant
|
||||
}
|
||||
}
|
||||
@@ -280,10 +289,34 @@ public struct ManagedLLMClient: LLMClient {
|
||||
)
|
||||
}
|
||||
|
||||
let token = try await grants.accessToken(
|
||||
for: capability.grantScope,
|
||||
forceRefresh: attempt.forceRefresh
|
||||
)
|
||||
let token: String
|
||||
switch requestPurpose {
|
||||
case .oobe:
|
||||
guard let oobeFeature else {
|
||||
throw ManagedGatewayError.server(
|
||||
code: "missing_oobe_feature",
|
||||
status: 400,
|
||||
requestId: attempt.requestId
|
||||
)
|
||||
}
|
||||
token = try await oobeGrants.accessToken(
|
||||
for: capability.grantScope,
|
||||
feature: oobeFeature,
|
||||
forceRefresh: attempt.forceRefresh
|
||||
)
|
||||
case nil:
|
||||
guard oobeFeature == nil else {
|
||||
throw ManagedGatewayError.server(
|
||||
code: "unexpected_oobe_feature",
|
||||
status: 400,
|
||||
requestId: attempt.requestId
|
||||
)
|
||||
}
|
||||
token = try await grants.accessToken(
|
||||
for: capability.grantScope,
|
||||
forceRefresh: attempt.forceRefresh
|
||||
)
|
||||
}
|
||||
let body = ManagedGatewayTextRequest(
|
||||
input: trimmedInput,
|
||||
context: boundedContext,
|
||||
@@ -291,7 +324,8 @@ public struct ManagedLLMClient: LLMClient {
|
||||
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
||||
stream: stream,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
|
||||
var request = URLRequest(
|
||||
@@ -307,6 +341,14 @@ public struct ManagedLLMClient: LLMClient {
|
||||
return request
|
||||
}
|
||||
|
||||
private func clearSelectedGrant() async throws {
|
||||
if requestPurpose == .oobe {
|
||||
try await oobeGrants.clearGrant()
|
||||
} else {
|
||||
try await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
|
||||
static func payload(
|
||||
from messages: [LLMRequest.Message]
|
||||
) -> (input: String, context: String?) {
|
||||
@@ -413,6 +455,9 @@ public struct ManagedLLMClient: LLMClient {
|
||||
if ["insufficient_credits", "insufficient_balance"].contains(code) {
|
||||
return .insufficientCredits
|
||||
}
|
||||
if code == "oobe_feature_already_used" {
|
||||
return .oobeFeatureAlreadyUsed
|
||||
}
|
||||
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
|
||||
return .invalidGrant
|
||||
}
|
||||
@@ -424,6 +469,7 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
public static func polish(
|
||||
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared
|
||||
@@ -432,6 +478,7 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: grants,
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
@@ -440,6 +487,8 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
|
||||
public static func ai(
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared
|
||||
@@ -447,6 +496,8 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
ManagedLLMClient(
|
||||
capability: .assistant,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: grants,
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// OOBEGatewayGrantCoordinator.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Routes anonymous onboarding requests through a credential slot that is
|
||||
// isolated from signed-in account grants.
|
||||
|
||||
import Foundation
|
||||
|
||||
public actor OOBEGatewayGrantCoordinator {
|
||||
public static let allowedScopes: Set<ManagedGatewayCapability> = [
|
||||
.polish,
|
||||
.assistant
|
||||
]
|
||||
|
||||
private let grants: GatewayGrantCoordinator
|
||||
private let store: any GatewayGrantCredentialStore
|
||||
private let now: @Sendable () -> Date
|
||||
private let practiceSession: @Sendable () -> OOBEPracticeSession?
|
||||
|
||||
public init(
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
store: any GatewayGrantCredentialStore = OOBEGatewayGrantKeychainStore(),
|
||||
session: URLSession = .shared,
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
practiceSession: @escaping @Sendable () -> OOBEPracticeSession? = {
|
||||
KeyboardSetupBridge.activeOOBEPracticeSession
|
||||
}
|
||||
) {
|
||||
self.store = store
|
||||
self.now = now
|
||||
self.practiceSession = practiceSession
|
||||
self.grants = GatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: store,
|
||||
session: session,
|
||||
refreshPath: "v1/oobe/grants/refresh",
|
||||
now: now,
|
||||
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
|
||||
)
|
||||
}
|
||||
|
||||
/// Host-only provisioning handoff after App Attest succeeds.
|
||||
public func install(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
guard credentials.scopes == Self.allowedScopes,
|
||||
credentials.hasUsableRefreshToken(at: now()),
|
||||
credentials.refreshExpiresAt
|
||||
<= credentials.receivedAt.addingTimeInterval(30 * 60 + 1) else {
|
||||
throw ManagedGatewayError.invalidGrant
|
||||
}
|
||||
try await store.save(credentials)
|
||||
}
|
||||
|
||||
public func accessToken(
|
||||
for capability: ManagedGatewayCapability,
|
||||
feature: ManagedGatewayOOBEFeature,
|
||||
forceRefresh: Bool = false
|
||||
) async throws -> String {
|
||||
guard Self.allowedScopes.contains(capability),
|
||||
feature.requiredCapability == capability else {
|
||||
throw ManagedGatewayError.scopeNotGranted(capability)
|
||||
}
|
||||
guard let practice = practiceSession(),
|
||||
practice.isActive(at: now()),
|
||||
practice.expectedFeature == feature else {
|
||||
try? await clearGrant()
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
return try await grants.accessToken(
|
||||
for: capability,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
}
|
||||
|
||||
public func clearGrant() async throws {
|
||||
try await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user