import Foundation
/// Supplies bearer tokens for the mailbox. Implement it to hand the SDK tokens
/// minted by your own backend (the path for apps whose `mailbox_open_mint` is off).
public protocol MailboxTokenProvider: Sendable {
/// A token valid for `room` (app-wide tokens qualify).
func token(for room: String) async throws -> MailboxToken
/// The node refused the token (401/403): forget it so the next call mints afresh.
func invalidate(for room: String) async
}
/// Mints tokens at the node's open mint (`GET <mint>?room=&ttl=`), sending the
/// configured `Origin`. App-scoped by default so one mint covers every room;
/// renewed five minutes before expiry; one in-flight mint at a time.
public actor OpenMintTokenProvider: MailboxTokenProvider {
public static let renewBefore: TimeInterval = 300
private let mintURL: URL
private let origin: String?
private let http: any HTTPTransport
private let ttlSeconds: Int
private let scopeToRoom: Bool
private let now: @Sendable () -> Date
private let timeout: TimeInterval
private var cache: [String: MailboxToken] = [:]
private var inflight: [String: Task<MailboxToken, any Error>] = [:]
public init(mintURL: URL, origin: String?, http: any HTTPTransport, ttlSeconds: Int = 86_400, scopeToRoom: Bool = false,
now: @escaping @Sendable () -> Date = { Date() }, timeout: TimeInterval = 15) {
self.mintURL = mintURL
self.origin = origin
self.http = http
self.ttlSeconds = ttlSeconds
self.scopeToRoom = scopeToRoom
self.now = now
self.timeout = timeout
}
private func key(_ room: String) -> String { scopeToRoom ? room : "" }
public func token(for room: String) async throws -> MailboxToken {
let k = key(room)
if let t = cache[k], t.expiresAt.timeIntervalSince(now()) > OpenMintTokenProvider.renewBefore { return t }
if let task = inflight[k] { return try await task.value }
let task = Task { try await self.mint(room: self.scopeToRoom ? room : nil) }
inflight[k] = task
defer { inflight[k] = nil }
let t = try await task.value
cache[k] = t
return t
}
public func invalidate(for room: String) {
cache[key(room)] = nil
}
private func mint(room: String?) async throws -> MailboxToken {
var comps = URLComponents(url: mintURL, resolvingAgainstBaseURL: false)!
var items = comps.queryItems ?? []
if let room { items.append(URLQueryItem(name: "room", value: room)) }
items.append(URLQueryItem(name: "ttl", value: String(ttlSeconds)))
comps.queryItems = items
// the room may contain '+' or '/' which URLComponents leaves alone in queries; encode them explicitly
comps.percentEncodedQuery = comps.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
let response = try await http.send(.p2p(comps.url!, origin: origin, timeout: timeout))
guard response.isSuccess else { throw MailboxWire.error(response) }
let m: MintResponse
do { m = try JSONDecoder().decode(MintResponse.self, from: response.body) } catch { throw P2PError.transport("mint response not decodable: \(error)") }
return MailboxToken(token: m.token, expiresAt: Date(timeIntervalSince1970: m.expires_at), room: m.room, endpoint: m.endpoint)
}
}
/// A fixed token, or a closure that fetches one (e.g. from your backend).
public struct StaticTokenProvider: MailboxTokenProvider {
private let fetch: @Sendable (String) async throws -> MailboxToken
public init(_ token: MailboxToken) { fetch = { _ in token } }
public init(_ fetch: @escaping @Sendable (String) async throws -> MailboxToken) { self.fetch = fetch }
public func token(for room: String) async throws -> MailboxToken { try await fetch(room) }
public func invalidate(for room: String) async {}
}