Files
ccn/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift
T

146 lines
4.6 KiB
Swift

import FluentKit
import Foundation
import HummingbirdFluent
/// Reads and creates accounts through the default database.
public struct AccountRepository: Sendable {
// MARK: Constants
/// The email of the demo buyer the prototype's stubbed passkey signs in.
public static let demoEmail = Seed.accounts[0].email
// MARK: Properties
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Fetches the account with the given email, without creating one.
/// - Returns: the account, or `nil` when no account has the email.
public func existingAccount(
email: String
) async throws -> User? {
try await UserRecord.query(on: fluent.db())
.filter(\.$email == email)
.first()?
.snapshot
}
/// Whether a credential identifier is still free to register.
public func isUnregistered(
credentialID: String
) async throws -> Bool {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.first() == nil
}
/// Creates an account together with its first passkey: one step both joins and enrolls.
///
/// A passkey is only ever added to an account created in the same step. Adding one to an existing account would hand that account to
/// whoever asks, since the prototype has no other proof of who is asking.
/// - Returns: the new account, or `nil` when the email already has one.
public func join(
email: String,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws -> User? {
try await fluent.db().transaction { database in
guard try await UserRecord.query(on: database)
.filter(\.$email == email)
.first() == nil
else {
return nil
}
let user = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await user.save(on: database)
try await PasskeyRecord(
userID: try user.requireID(),
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: database)
return try user.snapshot
}
}
/// Fetches the passkey with the given credential identifier, and the account it signs in.
public func passkey(
credentialID: String
) async throws -> Passkey? {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.with(\.$user)
.first()?
.snapshot
}
/// Fetches the passkeys registered to the account with the given email.
public func passkeys(
email: String
) async throws -> [Passkey] {
try await PasskeyRecord.query(on: fluent.db())
.join(UserRecord.self, on: \PasskeyRecord.$user.$id == \UserRecord.$id)
.filter(UserRecord.self, \.$email == email)
.with(\.$user)
.all()
.map { try $0.snapshot }
}
/// Records a passkey's signature counter after a sign-in.
public func recordUse(
ofPasskey credentialID: String,
signCount: UInt32
) async throws {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.set(\.$signCount, to: Int(signCount))
.update()
}
/// Fetches the account with the given email, creating it when there is none: one step both signs in and joins.
///
/// A new account is named after the email's local part until its owner picks a display name.
/// - Parameter email: the account's normalized email address.
/// - Returns: the account, existing or new.
public func account(
email: String
) async throws -> User {
if let record = try await UserRecord.query(on: fluent.db())
.filter(\.$email == email)
.first()
{
return try record.snapshot
}
let record = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await record.save(on: fluent.db())
return try record.snapshot
}
}