~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1

Sign in at the account's own PDS with atproto OAuth

Merged chadtmiller.com asked to merge into main from grain.social/grain-ios:feature/rust-auth
Grain/API/Endpoints/RecordEndpoints.swift+88−11
@@ -1,4 +1,7 @@
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "Records")
struct CreateRecordInput: Codable, Sendable {
let collection: String
@@ -15,14 +18,19 @@ struct PutRecordInput: Codable, Sendable {
let collection: String
let rkey: String
let record: AnyCodable
var repo: String?
let repo: String
}
struct DeleteRecordInput: Codable, Sendable {
let repo: String
let collection: String
let rkey: String
}
struct IndexRecordsInput: Codable, Sendable {
let uris: [String]
}
struct DeleteGalleryInput: Codable, Sendable {
let rkey: String
}
@@ -33,15 +41,16 @@ struct GetRecordResponse: Codable, Sendable {
var record: AnyCodable?
}
/// One write inside a `dev.hatk.applyWrites` batch.
/// One write inside a `com.atproto.repo.applyWrites` batch.
///
/// Grain only ever creates, and always with an explicit rkey, so a batch that
/// has to be retried addresses the same records both times.
/// Every write names its rkey, so a batch that has to be retried addresses
/// the same records both times.
struct ApplyWrite: Codable, Sendable {
let type: String
let collection: String
let rkey: String
let value: AnyCodable
/// The record, for a create or an update.
let value: AnyCodable?
enum CodingKeys: String, CodingKey {
case type = "$type"
@@ -51,11 +60,20 @@ struct ApplyWrite: Codable, Sendable {
}
static func create(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite {
ApplyWrite(type: "dev.hatk.applyWrites#create", collection: collection, rkey: rkey, value: value)
ApplyWrite(type: "com.atproto.repo.applyWrites#create", collection: collection, rkey: rkey, value: value)
}
static func update(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite {
ApplyWrite(type: "com.atproto.repo.applyWrites#update", collection: collection, rkey: rkey, value: value)
}
static func delete(collection: String, rkey: String) -> ApplyWrite {
ApplyWrite(type: "com.atproto.repo.applyWrites#delete", collection: collection, rkey: rkey, value: nil)
}
}
struct ApplyWritesInput: Codable, Sendable {
let repo: String
let writes: [ApplyWrite]
}
@@ -68,27 +86,86 @@ struct ApplyWritesResponse: Codable, Sendable {
var results: [ApplyWriteResult]?
}
/// Writes go to the account's PDS as `com.atproto.repo.*` calls, with the
/// account's DID as the repo. Each one that lands is then handed to the
/// AppView's `social.grain.sync.indexRecords`, so the next read sees it
/// without waiting on the firehose.
extension XRPCClient {
/// How many URIs one `indexRecords` call takes.
static let indexBatchSize = 25
func getRecord(uri: String, auth: AuthContext? = nil) async throws -> GetRecordResponse {
try await query("dev.hatk.getRecord", params: ["uri": uri], auth: auth, as: GetRecordResponse.self)
}
func createRecord(collection: String, repo: String, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse {
try await procedure("dev.hatk.createRecord", input: CreateRecordInput(collection: collection, repo: repo, record: record), auth: auth, as: CreateRecordResponse.self)
let response = try await procedure(
"com.atproto.repo.createRecord",
input: CreateRecordInput(collection: collection, repo: repo, record: record),
auth: auth,
as: CreateRecordResponse.self
)
await indexRecords([response.uri].compactMap(\.self), auth: auth)
return response
}
/// Create or overwrite the record at `rkey`. `repo` defaults to the
/// signed-in account's.
func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse {
try await procedure("dev.hatk.putRecord", input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo), auth: auth, as: CreateRecordResponse.self)
let repo = repo ?? auth?.did ?? ""
let response = try await procedure(
"com.atproto.repo.putRecord",
input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo),
auth: auth,
as: CreateRecordResponse.self
)
await indexRecords([response.uri ?? Self.uri(repo, collection, rkey)], auth: auth)
return response
}
/// Apply every write in a single atomic PDS commit. Either all the records
/// land or none do — there is no partially-created gallery to clean up.
/// land or none do: there is no partially-created gallery to clean up.
func applyWrites(_ writes: [ApplyWrite], auth: AuthContext? = nil) async throws -> ApplyWritesResponse {
try await procedure("dev.hatk.applyWrites", input: ApplyWritesInput(writes: writes), auth: auth, as: ApplyWritesResponse.self)
let repo = auth?.did ?? ""
let response = try await procedure(
"com.atproto.repo.applyWrites",
input: ApplyWritesInput(repo: repo, writes: writes),
auth: auth,
as: ApplyWritesResponse.self
)
await indexRecords(writes.map { Self.uri(repo, $0.collection, $0.rkey) }, auth: auth)
return response
}
func deleteRecord(collection: String, rkey: String, auth: AuthContext? = nil) async throws {
try await procedure("dev.hatk.deleteRecord", input: DeleteRecordInput(collection: collection, rkey: rkey), auth: auth)
let repo = auth?.did ?? ""
try await procedure(
"com.atproto.repo.deleteRecord",
input: DeleteRecordInput(repo: repo, collection: collection, rkey: rkey),
auth: auth
)
await indexRecords([Self.uri(repo, collection, rkey)], auth: auth)
}
/// Ask the AppView to index `uris` now, deleted ones included, in batches
/// it accepts. Only Grain's own collections are its to index; a Bluesky
/// cross-post is not. A failure is logged and never fails the write it
/// follows: the firehose brings the record in the end.
func indexRecords(_ uris: [String], auth: AuthContext?) async {
let uris = uris.filter { $0.contains("/social.grain.") }
guard let auth, !uris.isEmpty else { return }
for start in stride(from: 0, to: uris.count, by: Self.indexBatchSize) {
let batch = Array(uris[start ..< min(start + Self.indexBatchSize, uris.count)])
do {
try await procedure("social.grain.sync.indexRecords", input: IndexRecordsInput(uris: batch), auth: auth)
} catch {
logger.error("indexRecords failed for \(batch.count) record(s): \(error)")
}
}
}
static func uri(_ repo: String, _ collection: String, _ rkey: String) -> String {
"at://\(repo)/\(collection)/\(rkey)"
}
func deleteGallery(rkey: String, auth: AuthContext? = nil) async throws {
Grain/API/OAuth/IdentityResolver.swiftnew+161−0
@@ -0,0 +1,161 @@
import Foundation
/// An account as its DID document describes it.
struct ResolvedIdentity: Sendable, Equatable {
let did: String
/// The handle the DID document claims, when it resolves back to the DID.
let handle: String?
/// The `#atproto_pds` service endpoint.
let pds: URL
}
/// Handle and DID resolution, as atproto's OAuth asks a client to do before
/// it knows where an account signs in.
///
/// A handle resolves through its own `/.well-known/atproto-did` first, then
/// through `com.atproto.identity.resolveHandle` at the AppView: the resolvers,
/// in the order, that grain's CLI uses. A DID document comes from the PLC
/// directory for `did:plc`, or from its host for `did:web`.
struct IdentityResolver: Sendable {
let session: URLSession
var handleService = URL(string: "https://public.api.bsky.app")!
var plcDirectory = URL(string: "https://plc.directory")!
/// How long a lookup at a host the name alone points to may take: any
/// name can point at a host that takes the connection and never answers.
static let lookupTimeout: TimeInterval = 3
/// Resolve what the user typed, a handle (with or without `@`) or a DID.
func resolve(_ identifier: String) async throws -> ResolvedIdentity {
let typed = Self.normalize(identifier)
guard !typed.isEmpty else { throw OAuthError.handleRequired }
if typed.hasPrefix("did:") {
let document = try await document(for: typed)
let pds = try Self.pds(in: document, did: typed)
// Use the handle the document claims only if it resolves back.
var handle: String?
if let claimed = document.handle, let back = try? await resolveHandle(claimed), back == typed {
handle = claimed
}
return ResolvedIdentity(did: typed, handle: handle, pds: pds)
}
guard Self.looksLikeHandle(typed) else { throw OAuthError.handleNotFound(typed) }
let did = try await resolveHandle(typed)
let document = try await document(for: did)
let pds = try Self.pds(in: document, did: did)
let claims = document.alsoKnownAs?.contains { $0.lowercased() == "at://\(typed)" } ?? false
return ResolvedIdentity(did: did, handle: claims ? typed : nil, pds: pds)
}
/// A handle's DID, from the first resolver that answers with one.
func resolveHandle(_ handle: String) async throws -> String {
if let did = try? await wellKnown(handle) {
return did
}
var components = URLComponents(
url: handleService.appendingPathComponent("xrpc/com.atproto.identity.resolveHandle"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [URLQueryItem(name: "handle", value: handle)]
struct Resolved: Decodable { let did: String }
if let url = components.url,
case let (data, response)? = try? await session.data(from: url),
(response as? HTTPURLResponse)?.statusCode == 200,
let resolved = try? JSONDecoder().decode(Resolved.self, from: data),
resolved.did.hasPrefix("did:")
{
return resolved.did
}
throw OAuthError.handleNotFound(handle)
}
private func wellKnown(_ handle: String) async throws -> String {
guard let url = URL(string: "https://\(handle)/.well-known/atproto-did") else { throw OAuthError.handleNotFound(handle) }
var request = URLRequest(url: url)
request.timeoutInterval = Self.lookupTimeout
let (data, response) = try await session.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
did.hasPrefix("did:")
else { throw OAuthError.handleNotFound(handle) }
return did
}
/// `did`'s DID document.
func document(for did: String) async throws -> DIDDocument {
var request: URLRequest
if did.hasPrefix("did:plc:") {
request = URLRequest(url: plcDirectory.appendingPathComponent(did))
} else if did.hasPrefix("did:web:") {
let host = String(did.dropFirst("did:web:".count)).replacingOccurrences(of: "%3A", with: ":")
guard let url = URL(string: "https://\(host)/.well-known/did.json") else { throw OAuthError.unsupportedDID(did) }
request = URLRequest(url: url)
request.timeoutInterval = Self.lookupTimeout
} else {
throw OAuthError.unsupportedDID(did)
}
let (data, response) = try await session.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let document = try? JSONDecoder().decode(DIDDocument.self, from: data)
else { throw OAuthError.noPDS(did) }
return document
}
static func pds(in document: DIDDocument, did: String) throws -> URL {
guard let endpoint = document.service?.first(where: { $0.id.hasSuffix("#atproto_pds") })?.serviceEndpoint,
let url = URL(string: endpoint), url.scheme == "https" || url.scheme == "http"
else { throw OAuthError.noPDS(did) }
return url
}
static func normalize(_ identifier: String) -> String {
var typed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
if typed.hasPrefix("@") {
typed.removeFirst()
}
return typed.hasPrefix("did:") ? typed : typed.lowercased()
}
/// Enough of atproto's handle syntax to keep a typo off the network:
/// dot-separated labels of letters, digits and hyphens.
static func looksLikeHandle(_ handle: String) -> Bool {
let labels = handle.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 2, handle.count <= 253 else { return false }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
return labels.allSatisfy { label in
!label.isEmpty && label.count <= 63 && !label.hasPrefix("-") && !label.hasSuffix("-")
&& label.unicodeScalars.allSatisfy { allowed.contains($0) }
}
}
}
/// The parts of a DID document sign-in reads.
struct DIDDocument: Decodable, Sendable {
struct Service: Decodable, Sendable {
let id: String
let serviceEndpoint: String?
enum CodingKeys: String, CodingKey {
case id
case serviceEndpoint
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
// An endpoint may be an object or a list; only a URL string is a PDS.
serviceEndpoint = try? container.decode(String.self, forKey: .serviceEndpoint)
}
}
let id: String?
let alsoKnownAs: [String]?
let service: [Service]?
/// The handle in the first `at://` alias.
var handle: String? {
alsoKnownAs?.first { $0.hasPrefix("at://") }.map { String($0.dropFirst("at://".count)).lowercased() }
}
}
Grain/API/OAuth/OAuthClient.swiftnew+387−0
@@ -0,0 +1,387 @@
import CryptoKit
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "OAuth")
/// Why signing in failed, in words the login screen can show.
enum OAuthError: Error, LocalizedError, Equatable {
case handleRequired
case handleNotFound(String)
case unsupportedDID(String)
case noPDS(String)
/// The PDS names no authorization server, or that server publishes no
/// metadata.
case noAuthorizationServer(String)
/// The authorization server's metadata names a different issuer than the
/// one the PDS pointed at.
case issuerMismatch(expected: String, found: String)
/// The browser came back from a sign-in this one did not start.
case callbackMismatch
/// The tokens are for another account than the one asked for.
case wrongAccount(expected: String, found: String)
/// A sign-up named an account whose PDS signs in somewhere else.
case accountElsewhere(did: String)
case unsupportedTokenType(String)
/// The authorization server refused a request.
case refused(status: Int, error: String?, description: String?)
var errorDescription: String? {
switch self {
case .handleRequired:
"Enter your handle to sign in."
case let .handleNotFound(handle):
"Couldn't find an account for \(handle)."
case let .unsupportedDID(did):
"\(did) isn't a kind of account Grain can sign in to."
case let .noPDS(did):
"Couldn't find where \(did) is hosted."
case let .noAuthorizationServer(pds):
"\(pds) doesn't support signing in with OAuth."
case let .issuerMismatch(expected, _):
"The sign-in server at \(expected) is misconfigured."
case .callbackMismatch:
"Sign-in didn't complete. Try again."
case .wrongAccount:
"You signed in to a different account than the one you entered."
case .accountElsewhere:
"The new account isn't hosted where it was created."
case let .unsupportedTokenType(type):
"The sign-in server issued an unsupported token (\(type))."
case let .refused(status, error, description):
description ?? error.map { "Sign-in failed (\($0))" } ?? "Sign-in failed (HTTP \(status))"
}
}
}
/// An authorization server's endpoints, from its metadata.
struct AuthorizationServer: Codable, Sendable, Equatable {
let issuer: String
let authorizationEndpoint: URL
let tokenEndpoint: URL
let pushedAuthorizationRequestEndpoint: URL
let revocationEndpoint: URL?
enum CodingKeys: String, CodingKey {
case issuer
case authorizationEndpoint = "authorization_endpoint"
case tokenEndpoint = "token_endpoint"
case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"
case revocationEndpoint = "revocation_endpoint"
}
}
/// A token response.
struct OAuthTokens: Decodable, Sendable {
let accessToken: String
let tokenType: String
let expiresIn: Int?
let refreshToken: String?
let sub: String
let scope: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case sub
case scope
}
var expiresAt: Date {
Date().addingTimeInterval(TimeInterval(expiresIn ?? 300))
}
}
/// An authorization request pushed to the account's server, waiting for the
/// user to approve it in the browser.
struct PendingAuthorization: Sendable {
/// The page where the user approves it.
let url: URL
let state: String
/// Who the user said they are: nil for an account they are making.
let identity: ResolvedIdentity?
let server: AuthorizationServer
let dpop: DPoP
let verifier: String
let scope: String
}
/// A redeemed sign-in: everything an account keeps.
struct OAuthSession: Sendable {
let identity: ResolvedIdentity
let server: AuthorizationServer
let tokens: OAuthTokens
let dpop: DPoP
/// The scope granted, or the one asked for when the server does not say.
let scope: String
}
/// atproto OAuth as a public native client: PAR, PKCE (S256) and DPoP-bound
/// tokens, with each server's nonce kept per origin.
struct OAuthClient: Sendable {
let session: URLSession
let clientID: String
let redirectURI: String
var resolver: IdentityResolver
init(session: URLSession, clientID: String, redirectURI: String) {
self.session = session
self.clientID = clientID
self.redirectURI = redirectURI
resolver = IdentityResolver(session: session)
}
// MARK: - Discovery
/// The authorization server for accounts on `pds`.
func server(forPDS pds: URL) async throws -> AuthorizationServer {
struct Resource: Decodable {
let authorizationServers: [String]?
enum CodingKeys: String, CodingKey { case authorizationServers = "authorization_servers" }
}
let base = pds.absoluteString.trimmingSuffix("/")
guard let resourceURL = URL(string: "\(base)/.well-known/oauth-protected-resource"),
let resource: Resource = try? await getJSON(resourceURL),
let issuer = resource.authorizationServers?.first,
let metadataURL = URL(string: "\(issuer.trimmingSuffix("/"))/.well-known/oauth-authorization-server")
else { throw OAuthError.noAuthorizationServer(base) }
guard let server: AuthorizationServer = try? await getJSON(metadataURL) else {
throw OAuthError.noAuthorizationServer(issuer)
}
guard server.issuer == issuer else {
throw OAuthError.issuerMismatch(expected: issuer, found: server.issuer)
}
return server
}
private func getJSON<T: Decodable>(_ url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw OAuthError.refused(status: (response as? HTTPURLResponse)?.statusCode ?? 0, error: nil, description: nil)
}
return try JSONDecoder().decode(T.self, from: data)
}
// MARK: - Authorization
/// Push a request to act as `identity` with `scope`. `hint` is who the
/// user said they are.
func authorize(_ identity: ResolvedIdentity, hint: String, scope: String) async throws -> PendingAuthorization {
let server = try await server(forPDS: identity.pds)
return try await push(to: server, identity: identity, asking: ["login_hint": hint], scope: scope)
}
/// Push a request to act with `scope` as an account the user makes first
/// on `pds`, on the sign-up page its authorization server shows for
/// `prompt=create`. Who they are is known once it is redeemed.
func signUp(at pds: URL, scope: String) async throws -> PendingAuthorization {
let server = try await server(forPDS: pds)
return try await push(to: server, identity: nil, asking: ["prompt": "create"], scope: scope)
}
private func push(
to server: AuthorizationServer,
identity: ResolvedIdentity?,
asking: [String: String],
scope: String
) async throws -> PendingAuthorization {
let dpop = DPoP.createEphemeral()
let verifier = Self.random(32)
let state = Self.random(16)
var form = [
"client_id": clientID,
"redirect_uri": redirectURI,
"response_type": "code",
"code_challenge": Self.challenge(for: verifier),
"code_challenge_method": "S256",
"scope": scope,
"state": state,
]
form.merge(asking) { _, new in new }
struct Pushed: Decodable {
let requestURI: String
enum CodingKeys: String, CodingKey { case requestURI = "request_uri" }
}
let pushed: Pushed = try await post(server.pushedAuthorizationRequestEndpoint, form: form, dpop: dpop)
var components = URLComponents(url: server.authorizationEndpoint, resolvingAgainstBaseURL: false)!
components.queryItems = (components.queryItems ?? []) + [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "request_uri", value: pushed.requestURI),
]
guard let url = components.url else { throw XRPCError.invalidURL }
return PendingAuthorization(
url: url, state: state, identity: identity, server: server,
dpop: dpop, verifier: verifier, scope: scope
)
}
/// Trade the code the browser came back with for tokens, and check they
/// are for the account asked for.
func redeem(_ pending: PendingAuthorization, callback: URL) async throws -> OAuthSession {
let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
let params = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
guard params["state"] == pending.state else { throw OAuthError.callbackMismatch }
if let iss = params["iss"], iss != pending.server.issuer {
throw OAuthError.callbackMismatch
}
if let error = params["error"] {
throw error == "access_denied"
? XRPCError.authorizationDenied
: XRPCError.authorizationFailed(code: error, description: params["error_description"])
}
guard let code = params["code"] else { throw OAuthError.callbackMismatch }
let tokens: OAuthTokens = try await post(
pending.server.tokenEndpoint,
form: [
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirectURI,
"client_id": clientID,
"code_verifier": pending.verifier,
],
dpop: pending.dpop
)
guard tokens.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame else {
throw OAuthError.unsupportedTokenType(tokens.tokenType)
}
let identity: ResolvedIdentity = if let asked = pending.identity {
asked
} else {
try await made(tokens.sub, at: pending.server.issuer)
}
guard tokens.sub == identity.did else {
throw OAuthError.wrongAccount(expected: identity.did, found: tokens.sub)
}
return OAuthSession(
identity: identity, server: pending.server, tokens: tokens,
dpop: pending.dpop, scope: tokens.scope ?? pending.scope
)
}
/// The account `did`, which a sign-up at the server `issuer` made. As
/// atproto's OAuth asks, it is taken only if its PDS signs in there: else
/// any server could name any account.
func made(_ did: String, at issuer: String) async throws -> ResolvedIdentity {
let identity = try await resolver.resolve(did)
let theirs = try await server(forPDS: identity.pds).issuer
guard theirs == issuer else { throw OAuthError.accountElsewhere(did: did) }
return identity
}
// MARK: - Tokens
/// New tokens for a session, from the token endpoint it signed in at.
func refresh(refreshToken: String, tokenEndpoint: URL, dpop: DPoP) async throws -> OAuthTokens {
try await post(
tokenEndpoint,
form: [
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": clientID,
],
dpop: dpop
)
}
/// Revoke a session's refresh token, as far as its server lets it.
func revoke(refreshToken: String, revocationEndpoint: URL, dpop: DPoP) async throws {
_ = try await postForm(
revocationEndpoint,
form: ["token": refreshToken, "client_id": clientID],
dpop: dpop
)
}
// MARK: - Requests
private func post<T: Decodable>(_ url: URL, form: [String: String], dpop: DPoP) async throws -> T {
let data = try await postForm(url, form: form, dpop: dpop)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw XRPCError.decodingError(error)
}
}
/// POST `form` with a DPoP proof, sending it again once when the server
/// answers `use_dpop_nonce`.
private func postForm(_ url: URL, form: [String: String], dpop: DPoP) async throws -> Data {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = Data(form.formEncoded.utf8)
var attempt = 0
while true {
attempt += 1
try await request.setValue(dpop.createProof(httpMethod: "POST", url: url), forHTTPHeaderField: "DPoP")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw OAuthError.refused(status: 0, error: nil, description: nil)
}
DPoPNonces.shared.keep(from: http, for: url)
if (200 ... 299).contains(http.statusCode) {
return data
}
struct Refusal: Decodable {
let error: String?
let errorDescription: String?
enum CodingKeys: String, CodingKey {
case error
case errorDescription = "error_description"
}
}
let refusal = try? JSONDecoder().decode(Refusal.self, from: data)
if refusal?.error == "use_dpop_nonce", attempt == 1 {
continue
}
logger.error("OAuth \(url.path, privacy: .public) refused (\(http.statusCode)): \(refusal?.error ?? "no error", privacy: .public)")
throw OAuthError.refused(status: http.statusCode, error: refusal?.error, description: refusal?.errorDescription)
}
}
// MARK: - PKCE
static func random(_ bytes: Int) -> String {
var buffer = [UInt8](repeating: 0, count: bytes)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64URLEncoded()
}
static func challenge(for verifier: String) -> String {
Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
}
}
// MARK: - Helpers
extension [String: String] {
/// `application/x-www-form-urlencoded`, every byte but the unreserved
/// ones escaped: a scope carries `?`, `&`, `=` and `%`.
var formEncoded: String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return sorted { $0.key < $1.key }.map { key, value in
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: unreserved) ?? key
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value
return "\(escapedKey)=\(escapedValue)"
}.joined(separator: "&")
}
}
extension String {
func trimmingSuffix(_ suffix: String) -> String {
var trimmed = self
while trimmed.hasSuffix(suffix) {
trimmed.removeLast(suffix.count)
}
return trimmed
}
}
Grain/API/AuthManager.swift+180−309
489 lines changed: not loaded until asked for.
Grain/API/DPoP.swift+43−5
@@ -1,5 +1,6 @@
import CryptoKit
import Foundation
import os
import Security
/// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256.
@@ -26,24 +27,25 @@ final class DPoP: Sendable {
"y": yCoord.base64URLEncoded(),
]
// JWK thumbprint (RFC 7638) — lexicographic JSON of required members
// JWK thumbprint (RFC 7638): lexicographic JSON of required members
let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(xCoord.base64URLEncoded())","y":"\#(yCoord.base64URLEncoded())"}"#
let hash = SHA256.hash(data: Data(thumbprintInput.utf8))
thumbprint = Data(hash).base64URLEncoded()
}
/// Create a DPoP proof JWT.
/// Create a DPoP proof JWT for one request: `htm` and `htu` (the URL
/// without query or fragment), `ath` when it carries an access token, and
/// the nonce the request's origin last sent.
func createProof(
httpMethod: String,
url: URL,
accessToken: String? = nil,
nonce: String? = nil
accessToken: String? = nil
) async throws -> String {
// Normalize URL: scheme + host + path (no query)
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.query = nil
components.fragment = nil
let htu = components.url!.absoluteString
let nonce = DPoPNonces.shared.nonce(for: url)
// Header
let header: [String: Any] = [
@@ -79,6 +81,42 @@ final class DPoP: Sendable {
}
}
// MARK: - Nonces
/// The DPoP nonce each server last sent, by origin. An account's
/// authorization server and its PDS are different origins with different
/// nonces, and a proof carries the one for the origin it is sent to. Held in
/// memory only: a server hands out a fresh one on the first refusal.
final class DPoPNonces: Sendable {
static let shared = DPoPNonces()
private let nonces = OSAllocatedUnfairLock<[String: String]>(initialState: [:])
static func origin(of url: URL) -> String {
let scheme = url.scheme?.lowercased() ?? ""
let host = url.host?.lowercased() ?? ""
let port = url.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
func nonce(for url: URL) -> String? {
let origin = Self.origin(of: url)
return nonces.withLock { $0[origin] }
}
/// Keep the nonce `response` to a request for `url` carries, if any.
func keep(from response: HTTPURLResponse, for url: URL) {
guard let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), !nonce.isEmpty else { return }
let origin = Self.origin(of: url)
nonces.withLock { $0[origin] = nonce }
}
func set(_ nonce: String?, for url: URL) {
let origin = Self.origin(of: url)
nonces.withLock { $0[origin] = nonce }
}
}
// MARK: - Key Management
extension DPoP {
Grain/API/TokenStorage.swift+28−1
@@ -12,6 +12,17 @@ struct StoredAccount: Codable, Identifiable, Hashable {
}
}
/// Where an account signs in and where its repo lives: what a refresh, a
/// revocation and every signed-in call need besides the tokens.
struct AccountServer: Codable, Sendable, Equatable {
/// The account's PDS, where signed-in calls go.
let pds: URL
/// The authorization server that issued the tokens.
let issuer: String
let tokenEndpoint: URL
let revocationEndpoint: URL?
}
/// Secure storage for OAuth tokens using Keychain.
///
/// Every credential is namespaced by DID so several accounts can stay signed in
@@ -120,6 +131,16 @@ enum TokenStorage {
read("token_scope", did)
}
static func server(for did: String) -> AccountServer? {
guard let raw = read("oauth_server", did) else { return nil }
return try? JSONDecoder().decode(AccountServer.self, from: Data(raw.utf8))
}
static func storeServer(_ server: AccountServer, for did: String) {
guard let data = try? JSONEncoder().encode(server) else { return }
write("oauth_server", did, String(data: data, encoding: .utf8))
}
static func setAvatar(_ avatar: String?, for did: String) {
write("user_avatar", did, avatar)
}
@@ -150,11 +171,17 @@ enum TokenStorage {
accessToken(for: did) != nil && refreshToken(for: did) != nil
}
/// Whether `did` can make signed-in calls: tokens, and the PDS and
/// authorization server they belong to.
static func hasSession(for did: String) -> Bool {
hasCredentials(for: did) && server(for: did) != nil
}
/// Forget one account: wipe its credentials and drop it from the list.
/// Clears `activeDID` if it was the active one — the caller decides which
/// account, if any, takes over.
static func removeAccount(_ did: String) {
for name in ["access_token", "refresh_token", "user_handle", "user_avatar", "token_expires_at", "token_scope"] {
for name in ["access_token", "refresh_token", "user_handle", "user_avatar", "token_expires_at", "token_scope", "oauth_server"] {
write(name, did, nil)
}
accounts = accounts.filter { $0.did != did }
Grain/API/XRPCClient.swift+122−163
@@ -8,8 +8,7 @@ enum XRPCError: Error, LocalizedError {
case httpError(statusCode: Int, body: Data?)
case decodingError(Error)
case unauthorized
case dpopNonceRequired(nonce: String)
/// The PDS rejected the authorization — usually the user tapping "Deny".
/// The authorization server refused the request, usually the user tapping "Deny".
case authorizationDenied
/// Any other OAuth error handed back on the callback URL.
case authorizationFailed(code: String, description: String?)
@@ -20,14 +19,19 @@ enum XRPCError: Error, LocalizedError {
case let .httpError(code, _): "HTTP error \(code)"
case let .decodingError(error): "Decoding error: \(error.localizedDescription)"
case .unauthorized: "Unauthorized"
case .dpopNonceRequired: "DPoP nonce required"
case .authorizationDenied: "Sign-in canceled"
case let .authorizationFailed(code, description): description ?? "Sign-in failed (\(code))"
}
}
}
/// XRPC client for communicating with the hatk server.
/// XRPC client for Grain's calls.
///
/// A call without an auth context goes to `baseURL`, Grain's AppView, as is.
/// A call with one goes to the account's PDS, with a DPoP-bound token and a
/// proof per request; every method outside `com.atproto.*` also carries an
/// `atproto-proxy` header, so the PDS forwards it to the AppView with a
/// service token, as Bluesky's apps reach their AppView.
final class XRPCClient: Sendable {
let baseURL: URL
private let session: URLSession
@@ -50,16 +54,17 @@ final class XRPCClient: Sendable {
auth: AuthContext? = nil,
as type: T.Type
) async throws -> T {
var components = URLComponents(url: baseURL.appendingPathComponent("xrpc/\(nsid)"), resolvingAgainstBaseURL: false)!
if !params.isEmpty {
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
let data = try await perform(nsid, auth: auth) { base in
var components = URLComponents(url: base.appendingPathComponent("xrpc/\(nsid)"), resolvingAgainstBaseURL: false)!
if !params.isEmpty {
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components.url else { throw XRPCError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
return request
}
guard let url = components.url else { throw XRPCError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
return try await executeWithRetry(request, auth: auth, as: type)
return try decode(type, from: data)
}
/// Execute an XRPC procedure (POST request).
@@ -69,13 +74,11 @@ final class XRPCClient: Sendable {
auth: AuthContext? = nil,
as type: O.Type
) async throws -> O {
let url = baseURL.appendingPathComponent("xrpc/\(nsid)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(input)
return try await executeWithRetry(request, auth: auth, as: type)
let body = try encoder.encode(input)
let data = try await perform(nsid, auth: auth) { base in
Self.post(base, nsid, body: body, contentType: "application/json")
}
return try decode(type, from: data)
}
/// Execute an XRPC procedure with no response body.
@@ -84,132 +87,118 @@ final class XRPCClient: Sendable {
input: some Encodable,
auth: AuthContext? = nil
) async throws {
let url = baseURL.appendingPathComponent("xrpc/\(nsid)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(input)
try await executeVoidWithRetry(request, auth: auth)
let body = try encoder.encode(input)
_ = try await perform(nsid, auth: auth) { base in
Self.post(base, nsid, body: body, contentType: "application/json")
}
}
/// Upload a blob (binary data).
/// Upload a blob to the account's PDS.
func uploadBlob(
data: Data,
mimeType: String,
auth: AuthContext? = nil
) async throws -> UploadBlobResponse {
let url = baseURL.appendingPathComponent("xrpc/dev.hatk.uploadBlob")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
request.httpBody = data
let nsid = "com.atproto.repo.uploadBlob"
let response = try await perform(nsid, auth: auth) { base in
Self.post(base, nsid, body: data, contentType: mimeType)
}
return try decode(UploadBlobResponse.self, from: response)
}
return try await executeWithRetry(request, auth: auth, as: UploadBlobResponse.self)
/// The `atproto-proxy` value a signed-in call to `nsid` carries, if any.
static func proxy(for nsid: String) -> String? {
nsid.hasPrefix("com.atproto.") ? nil : AuthManager.appviewService
}
// MARK: - Private
private func executeWithRetry<T: Decodable>(
_ request: URLRequest,
auth: AuthContext?,
as type: T.Type,
retryCount: Int = 0
) async throws -> T {
var req = request
try await applyAuth(&req, auth: auth)
private static func post(_ base: URL, _ nsid: String, body: Data, contentType: String) -> URLRequest {
var request = URLRequest(url: base.appendingPathComponent("xrpc/\(nsid)"))
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
return request
}
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
return try await execute(req, as: type)
} catch let XRPCError.dpopNonceRequired(nonce) where retryCount < 2 {
logger.info("DPoP nonce required, retrying with nonce")
var updatedAuth = auth
updatedAuth?.nonce = nonce
var retryReq = request
try await applyAuth(&retryReq, auth: updatedAuth)
return try await execute(retryReq, as: type)
} catch XRPCError.unauthorized where retryCount < 1 {
if let onUnauthorized {
do {
if let newAuth = try await onUnauthorized() {
logger.info("Token refreshed, retrying request")
return try await executeWithRetry(request, auth: newAuth, as: type, retryCount: retryCount + 1)
}
} catch {
logger.error("Token refresh failed: \(error)")
}
}
throw XRPCError.unauthorized
return try decoder.decode(type, from: data)
} catch {
throw XRPCError.decodingError(error)
}
}
private func executeVoidWithRetry(
_ request: URLRequest,
auth: AuthContext?,
retryCount: Int = 0
) async throws {
var req = request
try await applyAuth(&req, auth: auth)
let (data, response) = try await send(req)
guard let httpResponse = response as? HTTPURLResponse else { return }
if httpResponse.statusCode == 400,
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"),
retryCount < 2
{
logger.info("DPoP nonce required (void), retrying")
var updatedAuth = auth
updatedAuth?.nonce = nonce
var retryReq = request
try await applyAuth(&retryReq, auth: updatedAuth)
let (_, retryResponse) = try await send(retryReq)
guard let retryHttp = retryResponse as? HTTPURLResponse else { return }
if retryHttp.statusCode == 401 {
throw XRPCError.unauthorized
/// Send the request `build` makes against the base URL a context calls
/// for, and return the body of a 2xx answer.
///
/// A DPoP nonce refusal (401 with `use_dpop_nonce` in `WWW-Authenticate`,
/// or 400 with it as the error) is sent again once with the nonce. Any
/// other 401 asks `onUnauthorized` for a renewed context once and sends
/// the request again with it.
private func perform(
_ nsid: String,
auth initial: AuthContext?,
build: (URL) throws -> URLRequest
) async throws -> Data {
var auth = initial
var refreshed = false
var nonceRetried = false
while true {
var request = try build(auth?.pds ?? baseURL)
try await applyAuth(&request, nsid: nsid, auth: auth)
let (data, response) = try await send(request)
guard let http = response as? HTTPURLResponse else {
throw XRPCError.httpError(statusCode: 0, body: data)
}
guard (200 ... 299).contains(retryHttp.statusCode) else {
throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil)
if let url = request.url {
DPoPNonces.shared.keep(from: http, for: url)
}
return
}
if httpResponse.statusCode == 401 {
// Try once more with DPoP-Nonce from response if present
if let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), retryCount < 2 {
logger.info("401 with nonce, retrying")
var updatedAuth = auth
updatedAuth?.nonce = nonce
var retryReq = request
try await applyAuth(&retryReq, auth: updatedAuth)
let (_, retryResponse) = try await send(retryReq)
guard let retryHttp = retryResponse as? HTTPURLResponse else { return }
if retryHttp.statusCode == 401 {
throw XRPCError.unauthorized
}
guard (200 ... 299).contains(retryHttp.statusCode) else {
throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil)
}
return
if auth != nil, !nonceRetried, Self.isNonceRefusal(http, body: data) {
logger.info("DPoP nonce required, retrying with nonce")
nonceRetried = true
continue
}
// Try token refresh
if retryCount < 1, let onUnauthorized {
do {
if let newAuth = try await onUnauthorized() {
logger.info("Token refreshed, retrying void request")
try await executeVoidWithRetry(request, auth: newAuth, retryCount: retryCount + 1)
return
if http.statusCode == 401 {
let wwwAuth = http.value(forHTTPHeaderField: "WWW-Authenticate")
logger.error("401 \(nsid, privacy: .public): wwwAuth=\(wwwAuth ?? "nil", privacy: .public)")
if !refreshed, let onUnauthorized {
refreshed = true
do {
if let renewed = try await onUnauthorized() {
logger.info("Token refreshed, retrying request")
auth = renewed
nonceRetried = false
continue
}
} catch {
logger.error("Token refresh failed: \(error)")
}
} catch {
logger.error("Token refresh failed: \(error)")
}
throw XRPCError.unauthorized
}
guard (200 ... 299).contains(http.statusCode) else {
logger.error("HTTP \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
throw XRPCError.httpError(statusCode: http.statusCode, body: data)
}
throw XRPCError.unauthorized
return data
}
}
guard (200 ... 299).contains(httpResponse.statusCode) else {
logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data)
static func isNonceRefusal(_ response: HTTPURLResponse, body: Data) -> Bool {
switch response.statusCode {
case 401:
return response.value(forHTTPHeaderField: "WWW-Authenticate")?.contains("use_dpop_nonce") ?? false
case 400:
struct Refusal: Decodable { let error: String? }
return (try? JSONDecoder().decode(Refusal.self, from: body))?.error == "use_dpop_nonce"
default:
return false
}
}
@@ -222,60 +211,30 @@ final class XRPCClient: Sendable {
return try await session.data(for: request)
}
private func execute<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response) = try await send(request)
guard let httpResponse = response as? HTTPURLResponse else {
throw XRPCError.httpError(statusCode: 0, body: data)
}
// Check for DPoP nonce requirement
if httpResponse.statusCode == 400,
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce")
{
throw XRPCError.dpopNonceRequired(nonce: nonce)
}
if httpResponse.statusCode == 401 {
let body = String(data: data, encoding: .utf8) ?? ""
let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce")
let wwwAuth = httpResponse.value(forHTTPHeaderField: "WWW-Authenticate")
logger.error("401: body=\(body), nonce=\(nonce ?? "nil"), wwwAuth=\(wwwAuth ?? "nil")")
if let nonce {
throw XRPCError.dpopNonceRequired(nonce: nonce)
}
throw XRPCError.unauthorized
}
guard (200 ... 299).contains(httpResponse.statusCode) else {
logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data)
}
do {
return try decoder.decode(type, from: data)
} catch {
throw XRPCError.decodingError(error)
}
}
private func applyAuth(_ request: inout URLRequest, auth: AuthContext?) async throws {
guard let auth else { return }
private func applyAuth(_ request: inout URLRequest, nsid: String, auth: AuthContext?) async throws {
guard let auth, let url = request.url else { return }
request.setValue("DPoP \(auth.accessToken)", forHTTPHeaderField: "Authorization")
let proof = try await auth.dpop.createProof(
httpMethod: request.httpMethod ?? "GET",
url: request.url!,
accessToken: auth.accessToken,
nonce: auth.nonce
url: url,
accessToken: auth.accessToken
)
request.setValue(proof, forHTTPHeaderField: "DPoP")
if let proxy = Self.proxy(for: nsid) {
request.setValue(proxy, forHTTPHeaderField: "atproto-proxy")
}
}
}
/// Context for authenticated requests.
/// Context for authenticated requests: the active account's token, its DPoP
/// key, and where its repo lives.
struct AuthContext: Sendable {
let accessToken: String
let dpop: DPoP
var nonce: String?
/// The account's DID, the repo its writes go to.
let did: String
/// The account's PDS, where every call carrying this context goes.
let pds: URL
}
struct UploadBlobResponse: Codable, Sendable {
Grain/Utilities/BlueskyPost.swift+2−2
@@ -122,7 +122,7 @@ enum BlueskyPost {
// 6. Write the record
let result: CreateRecordResponse
if let rkey {
logger.info(" calling dev.hatk.putRecord with collection=app.bsky.feed.post, repo=\(repo), rkey=\(rkey)")
logger.info(" calling com.atproto.repo.putRecord with collection=app.bsky.feed.post, repo=\(repo), rkey=\(rkey)")
result = try await client.putRecord(
collection: "app.bsky.feed.post",
rkey: rkey,
@@ -131,7 +131,7 @@ enum BlueskyPost {
auth: auth
)
} else {
logger.info(" calling dev.hatk.createRecord with collection=app.bsky.feed.post, repo=\(repo)")
logger.info(" calling com.atproto.repo.createRecord with collection=app.bsky.feed.post, repo=\(repo)")
result = try await client.createRecord(
collection: "app.bsky.feed.post",
repo: repo,
GrainTests/Helpers/Fixtures.swift+11−0
@@ -268,3 +268,14 @@ extension Fixtures {
]
}
}
extension AccountServer {
/// Where a test account's tokens came from: a PDS that is its own
/// authorization server, as a self-hosted one is.
static let test = AccountServer(
pds: URL(string: "https://pds.test")!,
issuer: "https://pds.test",
tokenEndpoint: URL(string: "https://pds.test/oauth/token")!,
revocationEndpoint: URL(string: "https://pds.test/oauth/revoke")!
)
}
GrainTests/Helpers/ViewRender.swift+1−0
@@ -188,6 +188,7 @@ final class TestAccount {
expiresAt: Date().addingTimeInterval(3600),
scope: AuthManager.requiredScopes.joined(separator: " ")
)
TokenStorage.storeServer(.test, for: did)
TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil))
// Making it the active account is what matters: `AuthManager.init`
GrainTests/AuthManagerAccountTests.swift+6−3
@@ -65,6 +65,7 @@ final class AuthManagerAccountTests: GrainTestCase {
expiresAt: Date().addingTimeInterval(expiresIn),
scope: AuthManager.requiredScopes.joined(separator: " ")
)
TokenStorage.storeServer(.test, for: did)
TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil))
}
@@ -79,8 +80,9 @@ final class AuthManagerAccountTests: GrainTestCase {
/// These are registered with the PDS; changing one without updating the
/// client metadata breaks sign-in for everyone.
func testTheOAuthIdentifiersAreTheRegisteredOnes() {
XCTAssertEqual(AuthManager.clientID, "grain-native://app")
XCTAssertEqual(AuthManager.redirectURI, "grain://oauth/callback")
XCTAssertEqual(AuthManager.clientID, AuthManager.serverURL.absoluteString + "/oauth/app-client-metadata.json")
XCTAssertEqual(AuthManager.redirectURI, "social.grain:/oauth/callback")
XCTAssertTrue(AuthManager.redirectURI.hasPrefix(AuthManager.callbackScheme + ":/"))
XCTAssertFalse(AuthManager.requiredScopes.isEmpty)
XCTAssertTrue(AuthManager.requiredScopes.contains("atproto"))
}
@@ -292,7 +294,8 @@ final class AuthManagerAccountTests: GrainTestCase {
let context = await auth.authContext()
XCTAssertEqual(context?.accessToken, "alice.test-access")
XCTAssertNil(context?.nonce, "A fresh context starts without a nonce; the PDS supplies one")
XCTAssertEqual(context?.did, alice)
XCTAssertEqual(context?.pds, AccountServer.test.pds, "Signed-in calls go to the account's PDS")
}
// MARK: - AccountError
GrainTests/AuthRefreshTests.swift+3−14
GrainTests/BlueskyCrossPostTests.swift+3−3
GrainTests/CrossPostPublishTests.swift+13−13
GrainTests/EndpointContractTests.swift+124−10
GrainTests/GalleryPublishTests.swift+19−19
GrainTests/GalleryServiceTests.swift+2−2
GrainTests/OAuthSignInTests.swiftnew+526−0
526 lines changed: not loaded until asked for.
GrainTests/PDSCallTests.swiftnew+222−0
GrainTests/ProfileDetailViewModelTests.swift+1−1
GrainTests/ProfileFavoritesAndModerationTests.swift+1−1
GrainTests/StoryCommentsViewModelTests.swift+1−1
GrainTests/StoryPublishTests.swift+8−8
GrainTests/StoryViewSyncTests.swift+1−1
GrainTests/XRPCClientTests.swift+14−11
project.yml+5−1