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() } /// Adds a passkey to an account. Only ever for the account's own session: holding one is the proof of who is asking. public func addPasskey( to user: User, credentialID: String, publicKey: [UInt8], signCount: UInt32 ) async throws { try await PasskeyRecord( userID: user.id, credentialID: credentialID, publicKey: publicKey, signCount: signCount, at: .init() ).save(on: fluent.db()) } /// Replaces every passkey of an account with a new one: what recovering an account comes down to. public func replacePasskeys( of user: User, credentialID: String, publicKey: [UInt8], signCount: UInt32 ) async throws { try await fluent.db().transaction { database in try await PasskeyRecord.query(on: database) .filter(\.$user.$id == user.id) .delete() try await PasskeyRecord( userID: user.id, credentialID: credentialID, publicKey: publicKey, signCount: signCount, at: .init() ).save(on: database) } } /// Fetches the passkeys registered to an account, the oldest first, with when each was created. public func passkeys( of user: User ) async throws -> [(passkey: Passkey, createdAt: Date)] { try await PasskeyRecord.query(on: fluent.db()) .filter(\.$user.$id == user.id) .sort(\.$createdAt) .with(\.$user) .all() .map { (try $0.snapshot, $0.createdAt) } } /// Changes an account's display name. /// - Returns: the account, renamed. public func rename( _ user: User, to displayName: String ) async throws -> User { try await UserRecord.query(on: fluent.db()) .filter(\.$id == user.id) .set(\.$displayName, to: displayName) .update() return .init( id: user.id, email: user.email, displayName: displayName ) } /// Revokes one of an account's passkeys, taking effect at once. public func revokePasskey( _ credentialID: String, of user: User ) async throws { try await PasskeyRecord.query(on: fluent.db()) .filter(\.$credentialID == credentialID) .filter(\.$user.$id == user.id) .delete() } /// 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 { try await signIn(email: email).user } /// Fetches the account with the given email, creating it when there is none, and says which happened. /// - Returns: the account, and whether this step created it. public func signIn( email: String ) async throws -> (user: User, isNew: Bool) { if let user = try await existingAccount(email: email) { return (user, false) } let record = UserRecord( email: email, displayName: String(email.prefix { $0 != "@" }) ) try await record.save(on: fluent.db()) return (try record.snapshot, true) } }