2026-09-21 11:44:41 +02:00
|
|
|
import FluentKit
|
2026-09-21 12:50:53 +02:00
|
|
|
import Foundation
|
2026-09-21 11:44:41 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-21 12:50:53 +02:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:31:44 +02:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:44:41 +02:00
|
|
|
/// 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 {
|
2026-09-21 13:31:44 +02:00
|
|
|
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)
|
2026-09-21 11:44:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let record = UserRecord(
|
|
|
|
|
email: email,
|
|
|
|
|
displayName: String(email.prefix { $0 != "@" })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try await record.save(on: fluent.db())
|
|
|
|
|
|
2026-09-21 13:31:44 +02:00
|
|
|
return (try record.snapshot, true)
|
2026-09-21 11:44:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|