feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -0,0 +1,308 @@
// AccountModels.swift
// OSGKeyboard · HostSupport
//
// Wire models for the host-private OSG account protocol.
import Foundation
public struct AccountSession: Codable, Equatable, Sendable {
public let accountId: UUID
public let tokenType: String
public let accessToken: String
public let accessTokenExpiresAtEpochSeconds: Int64
public let refreshToken: String
public let refreshTokenExpiresAtEpochSeconds: Int64
public init(
accountId: UUID,
tokenType: String,
accessToken: String,
accessTokenExpiresAtEpochSeconds: Int64,
refreshToken: String,
refreshTokenExpiresAtEpochSeconds: Int64
) {
self.accountId = accountId
self.tokenType = tokenType
self.accessToken = accessToken
self.accessTokenExpiresAtEpochSeconds = accessTokenExpiresAtEpochSeconds
self.refreshToken = refreshToken
self.refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAtEpochSeconds
}
}
public struct OSGAccount: Codable, Equatable, Sendable {
public let id: UUID
public let createdAtEpochSeconds: Int64
public let displayName: String?
public init(id: UUID, createdAtEpochSeconds: Int64, displayName: String? = nil) {
self.id = id
self.createdAtEpochSeconds = createdAtEpochSeconds
self.displayName = displayName
}
}
struct UpdateAccountProfileRequest: Codable, Equatable, Sendable {
let displayName: String
}
/// Exact authenticated resources exposed to the host account center. Keeping
/// this closed prevents callers from forwarding account tokens to arbitrary
/// paths or origins.
public enum AccountAuthorizedResource: Sendable, Equatable {
case creditsBalance
case referralProfile
case referralCampaigns
case referrals(limit: Int)
case createReferralCode
case redeemReferral
case storeKitProducts
case storeKitTransactions(limit: Int, cursor: String?)
case submitStoreKitTransaction
case revokeGatewayGrant(UUID)
var method: String {
switch self {
case .createReferralCode, .redeemReferral, .submitStoreKitTransaction:
return "POST"
case .revokeGatewayGrant:
return "DELETE"
default:
return "GET"
}
}
var path: String {
switch self {
case .creditsBalance:
return "/v1/credits/balance"
case .referralProfile:
return "/v1/referrals/me"
case .referralCampaigns:
return "/v1/referrals/campaigns"
case .referrals(let limit):
return "/v1/referrals?limit=\(min(max(limit, 1), 100))"
case .createReferralCode:
return "/v1/referrals/code"
case .redeemReferral:
return "/v1/referrals/redeem"
case .storeKitProducts:
return "/v1/storekit/products"
case .storeKitTransactions(let limit, let cursor):
let boundedLimit = min(max(limit, 1), 100)
var components = URLComponents()
components.path = "/v1/storekit/transactions"
components.queryItems = [
URLQueryItem(name: "limit", value: String(boundedLimit))
]
if let cursor {
components.queryItems?.append(
URLQueryItem(name: "cursor", value: cursor)
)
}
return components.string
?? "/v1/storekit/transactions?limit=\(boundedLimit)"
case .submitStoreKitTransaction:
return "/v1/storekit/transactions"
case .revokeGatewayGrant(let grantId):
return "/v1/gateway/grants/\(grantId.uuidString.lowercased())"
}
}
}
public struct AppleSignInCredential: Equatable, Sendable {
public let identityToken: String
public let authorizationCode: String
public init(identityToken: String, authorizationCode: String) {
self.identityToken = identityToken
self.authorizationCode = authorizationCode
}
}
public struct AppleSignInNonce: Equatable, Sendable {
public let rawValue: String
public let sha256Hex: String
public init(rawValue: String, sha256Hex: String) {
self.rawValue = rawValue
self.sha256Hex = sha256Hex
}
}
public struct AppleSignInRequest: Codable, Equatable, Sendable {
public let identityToken: String
public let authorizationCode: String
public let nonce: String
public let displayName: String?
public let deviceCheckToken: String?
public let appAttest: AppAttestAssertion?
public init(
identityToken: String,
authorizationCode: String,
nonce: String,
displayName: String? = nil,
deviceCheckToken: String?,
appAttest: AppAttestAssertion?
) {
self.identityToken = identityToken
self.authorizationCode = authorizationCode
self.nonce = nonce
self.displayName = displayName
self.deviceCheckToken = deviceCheckToken
self.appAttest = appAttest
}
}
public struct AppAttestAssertion: Codable, Equatable, Sendable {
public let keyId: String
public let challengeId: UUID
public let challenge: String
public let assertion: String
public init(keyId: String, challengeId: UUID, challenge: String, assertion: String) {
self.keyId = keyId
self.challengeId = challengeId
self.challenge = challenge
self.assertion = assertion
}
}
public enum AppAttestChallengePurpose: String, Codable, Sendable {
case attestation
case assertion
}
public struct AppAttestChallenge: Codable, Equatable, Sendable {
public let challengeId: UUID
public let challenge: String
public let expiresAtEpochSeconds: Int64
public init(challengeId: UUID, challenge: String, expiresAtEpochSeconds: Int64) {
self.challengeId = challengeId
self.challenge = challenge
self.expiresAtEpochSeconds = expiresAtEpochSeconds
}
}
public struct AppAttestKeyState: Codable, Equatable, Sendable {
public let keyId: String
public let isRegistered: Bool
public init(keyId: String, isRegistered: Bool) {
self.keyId = keyId
self.isRegistered = isRegistered
}
}
public protocol AccountSessionVault: Sendable {
func loadSession() async throws -> AccountSession?
func saveSession(_ session: AccountSession) async throws
func clearSession() async throws
}
public protocol AppAttestKeyStateStoring: Sendable {
func loadAppAttestKeyState() async throws -> AppAttestKeyState?
func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws
func clearAppAttestKeyState() async throws
}
public enum AccountAPIError: Error, Equatable, Sendable {
case invalidRequest(String)
case unauthorized(String)
case refreshTokenReuse
case externalServiceUnavailable(String)
case conflict(String)
case rateLimited(String)
case server(statusCode: Int, code: String, message: String)
case transport
case invalidResponse
case decoding
case secureStorage
case sessionUnavailable
case appleAuthorization
case integrityUnavailable
}
extension AccountAPIError: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidRequest(let message),
.unauthorized(let message),
.externalServiceUnavailable(let message),
.conflict(let message),
.rateLimited(let message):
return message
case .refreshTokenReuse:
return "The session was revoked because refresh-token reuse was detected."
case .server(_, _, let message):
return message
case .transport:
return "The account service could not be reached."
case .invalidResponse:
return "The account service returned an invalid response."
case .decoding:
return "The account service response could not be decoded."
case .secureStorage:
return "The private account session could not be stored securely."
case .sessionUnavailable:
return "No account session is available."
case .appleAuthorization:
return "Sign in with Apple did not return valid credentials."
case .integrityUnavailable:
return "Device integrity verification is unavailable."
}
}
}
struct APIDataEnvelope<Value: Codable & Sendable>: Codable, Sendable {
let data: Value
}
struct APIErrorEnvelope: Codable, Sendable {
let error: APIErrorPayload
}
struct APIErrorPayload: Codable, Sendable {
let code: String
let message: String
}
struct LegacyAPIErrorEnvelope: Codable, Sendable {
let error: String
}
struct RefreshSessionRequest: Codable, Sendable {
let refreshToken: String
}
struct DeleteAccountRequest: Codable, Sendable {
let identityToken: String
let authorizationCode: String
let nonce: String
}
struct AppAttestChallengeRequest: Codable, Sendable {
let purpose: AppAttestChallengePurpose
let keyId: String
}
struct AppAttestationRequest: Codable, Sendable {
let challengeId: UUID
let challenge: String
let keyId: String
let attestationObject: String
}
struct AppAssertionRequest: Codable, Sendable {
let challengeId: UUID
let challenge: String
let keyId: String
let assertion: String
let clientDataHash: String
}
struct AppAssertionResponse: Codable, Sendable {
let counter: Int64
}