diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/common_v1_common.pb.swift b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/common_v1_common.pb.swift index 4af1e4512..09ac91df8 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/common_v1_common.pb.swift +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/common_v1_common.pb.swift @@ -173,6 +173,21 @@ public struct Flipcash_Common_V1_UserId: Sendable { public init() {} } +/// Username is a user's unique handle on Flipcash. It uses the same character +/// set as X — letters, digits and underscores — with the exception that it must +/// be lowercase. +public struct Flipcash_Common_V1_Username: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var value: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Flipcash_Common_V1_ChatId: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -747,6 +762,36 @@ extension Flipcash_Common_V1_UserId: SwiftProtobuf.Message, SwiftProtobuf._Messa } } +extension Flipcash_Common_V1_Username: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Username" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}value\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.value) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.value.isEmpty { + try visitor.visitSingularStringField(value: self.value, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Common_V1_Username, rhs: Flipcash_Common_V1_Username) -> Bool { + if lhs.value != rhs.value {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Flipcash_Common_V1_ChatId: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ChatId" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}value\0") diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift index de0599af1..b555f55cd 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift @@ -25,9 +25,31 @@ public struct Flipcash_Profile_V1_UserProfile: Sendable { // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// The ID of the user this profile belongs to. Always set, so a caller that + /// looked the profile up by username learns the user's ID from the response. + public var userID: Flipcash_Common_V1_UserId { + get {return _userID ?? Flipcash_Common_V1_UserId()} + set {_userID = newValue} + } + /// Returns true if `userID` has been explicitly set. + public var hasUserID: Bool {return self._userID != nil} + /// Clears the value of `userID`. Subsequent reads from it will return its default value. + public mutating func clearUserID() {self._userID = nil} + /// Display name is the display name of the user (if found). public var displayName: String = String() + /// The user's username on Flipcash. Public, so it is returned for any user, + /// not just the caller. Unset when the user hasn't claimed one yet. + public var username: Flipcash_Common_V1_Username { + get {return _username ?? Flipcash_Common_V1_Username()} + set {_username = newValue} + } + /// Returns true if `username` has been explicitly set. + public var hasUsername: Bool {return self._username != nil} + /// Clears the value of `username`. Subsequent reads from it will return its default value. + public mutating func clearUsername() {self._username = nil} + /// Social profiles are links to external social accounts public var socialProfiles: [Flipcash_Profile_V1_SocialProfile] = [] @@ -97,6 +119,8 @@ public struct Flipcash_Profile_V1_UserProfile: Sendable { public init() {} + fileprivate var _userID: Flipcash_Common_V1_UserId? = nil + fileprivate var _username: Flipcash_Common_V1_Username? = nil fileprivate var _phoneNumber: Flipcash_Common_V1_PhoneNumber? = nil fileprivate var _emailAddress: Flipcash_Common_V1_EmailAddress? = nil fileprivate var _profilePicture: Flipcash_Blob_V1_Media? = nil @@ -232,7 +256,7 @@ fileprivate let _protobuf_package = "flipcash.profile.v1" extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".UserProfile" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}display_name\0\u{3}social_profiles\0\u{3}phone_number\0\u{3}email_address\0\u{3}profile_picture\0\u{3}join_ts\0\u{3}tip_card_customization\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}display_name\0\u{3}social_profiles\0\u{3}phone_number\0\u{3}email_address\0\u{3}profile_picture\0\u{3}join_ts\0\u{3}tip_card_customization\0\u{1}username\0\u{3}user_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -247,6 +271,8 @@ extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf. case 5: try { try decoder.decodeSingularMessageField(value: &self._profilePicture) }() case 6: try { try decoder.decodeSingularMessageField(value: &self._joinTs) }() case 7: try { try decoder.decodeSingularMessageField(value: &self._tipCardCustomization) }() + case 8: try { try decoder.decodeSingularMessageField(value: &self._username) }() + case 9: try { try decoder.decodeSingularMessageField(value: &self._userID) }() default: break } } @@ -278,11 +304,19 @@ extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf. try { if let v = self._tipCardCustomization { try visitor.visitSingularMessageField(value: v, fieldNumber: 7) } }() + try { if let v = self._username { + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + } }() + try { if let v = self._userID { + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Flipcash_Profile_V1_UserProfile, rhs: Flipcash_Profile_V1_UserProfile) -> Bool { + if lhs._userID != rhs._userID {return false} if lhs.displayName != rhs.displayName {return false} + if lhs._username != rhs._username {return false} if lhs.socialProfiles != rhs.socialProfiles {return false} if lhs._phoneNumber != rhs._phoneNumber {return false} if lhs._emailAddress != rhs._emailAddress {return false} diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_profile_service.pb.swift b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_profile_service.pb.swift index a011cc781..da9065ce0 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_profile_service.pb.swift +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_profile_service.pb.swift @@ -25,14 +25,25 @@ public struct Flipcash_Profile_V1_GetProfileRequest: Sendable { // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// The user whose profile is being fetched, identified either by their user + /// ID or by their username. Exactly one must be set. + public var identifier: Flipcash_Profile_V1_GetProfileRequest.OneOf_Identifier? = nil + public var userID: Flipcash_Common_V1_UserId { - get {return _userID ?? Flipcash_Common_V1_UserId()} - set {_userID = newValue} + get { + if case .userID(let v)? = identifier {return v} + return Flipcash_Common_V1_UserId() + } + set {identifier = .userID(newValue)} + } + + public var username: Flipcash_Common_V1_Username { + get { + if case .username(let v)? = identifier {return v} + return Flipcash_Common_V1_Username() + } + set {identifier = .username(newValue)} } - /// Returns true if `userID` has been explicitly set. - public var hasUserID: Bool {return self._userID != nil} - /// Clears the value of `userID`. Subsequent reads from it will return its default value. - public mutating func clearUserID() {self._userID = nil} /// Optional auth to retrieve private profile information for self public var auth: Flipcash_Common_V1_Auth { @@ -46,9 +57,16 @@ public struct Flipcash_Profile_V1_GetProfileRequest: Sendable { public var unknownFields = SwiftProtobuf.UnknownStorage() + /// The user whose profile is being fetched, identified either by their user + /// ID or by their username. Exactly one must be set. + public enum OneOf_Identifier: Equatable, Sendable { + case userID(Flipcash_Common_V1_UserId) + case username(Flipcash_Common_V1_Username) + + } + public init() {} - fileprivate var _userID: Flipcash_Common_V1_UserId? = nil fileprivate var _auth: Flipcash_Common_V1_Auth? = nil } @@ -617,7 +635,7 @@ fileprivate let _protobuf_package = "flipcash.profile.v1" extension Flipcash_Profile_V1_GetProfileRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".GetProfileRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{1}auth\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{1}auth\0\u{1}username\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -625,8 +643,33 @@ extension Flipcash_Profile_V1_GetProfileRequest: SwiftProtobuf.Message, SwiftPro // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { - case 1: try { try decoder.decodeSingularMessageField(value: &self._userID) }() + case 1: try { + var v: Flipcash_Common_V1_UserId? + var hadOneofValue = false + if let current = self.identifier { + hadOneofValue = true + if case .userID(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.identifier = .userID(v) + } + }() case 2: try { try decoder.decodeSingularMessageField(value: &self._auth) }() + case 3: try { + var v: Flipcash_Common_V1_Username? + var hadOneofValue = false + if let current = self.identifier { + hadOneofValue = true + if case .username(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.identifier = .username(v) + } + }() default: break } } @@ -637,17 +680,20 @@ extension Flipcash_Profile_V1_GetProfileRequest: SwiftProtobuf.Message, SwiftPro // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 - try { if let v = self._userID { + try { if case .userID(let v)? = self.identifier { try visitor.visitSingularMessageField(value: v, fieldNumber: 1) } }() try { if let v = self._auth { try visitor.visitSingularMessageField(value: v, fieldNumber: 2) } }() + try { if case .username(let v)? = self.identifier { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Flipcash_Profile_V1_GetProfileRequest, rhs: Flipcash_Profile_V1_GetProfileRequest) -> Bool { - if lhs._userID != rhs._userID {return false} + if lhs.identifier != rhs.identifier {return false} if lhs._auth != rhs._auth {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/resolver_v1_model.pb.swift b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/resolver_v1_model.pb.swift index b1d27ce71..914755e28 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/resolver_v1_model.pb.swift +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/resolver_v1_model.pb.swift @@ -45,11 +45,20 @@ public struct Flipcash_Resolver_V1_Identifier: Sendable { set {kind = .userID(newValue)} } + public var username: Flipcash_Common_V1_Username { + get { + if case .username(let v)? = kind {return v} + return Flipcash_Common_V1_Username() + } + set {kind = .username(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum OneOf_Kind: Equatable, Sendable { case phone(Flipcash_Common_V1_PhoneNumber) case userID(Flipcash_Common_V1_UserId) + case username(Flipcash_Common_V1_Username) } @@ -89,7 +98,7 @@ fileprivate let _protobuf_package = "flipcash.resolver.v1" extension Flipcash_Resolver_V1_Identifier: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Identifier" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}phone\0\u{3}user_id\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}phone\0\u{3}user_id\0\u{1}username\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -123,6 +132,19 @@ extension Flipcash_Resolver_V1_Identifier: SwiftProtobuf.Message, SwiftProtobuf. self.kind = .userID(v) } }() + case 3: try { + var v: Flipcash_Common_V1_Username? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .username(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .username(v) + } + }() default: break } } @@ -142,6 +164,10 @@ extension Flipcash_Resolver_V1_Identifier: SwiftProtobuf.Message, SwiftProtobuf. guard case .userID(let v)? = self.kind else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) }() + case .username?: try { + guard case .username(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/common/v1/common.proto b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/common/v1/common.proto index 73a1eafa5..21d46b01a 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/common/v1/common.proto +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/common/v1/common.proto @@ -61,6 +61,13 @@ message UserId { }]; } +// Username is a user's unique handle on Flipcash. It uses the same character +// set as X — letters, digits and underscores — with the exception that it must +// be lowercase. +message Username { + string value = 1 [(validate.rules).string.pattern = "^[a-z0-9_]{2,15}$"]; +} + message ChatId { // value has the following structure: // - 32 byte hash for DMs diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto index 20e447c27..c02c0bb75 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto @@ -12,12 +12,20 @@ import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; message UserProfile { + // The ID of the user this profile belongs to. Always set, so a caller that + // looked the profile up by username learns the user's ID from the response. + common.v1.UserId user_id = 9 [(validate.rules).message.required = true]; + // Display name is the display name of the user (if found). string display_name = 1 [(validate.rules).string = { min_len: 0 max_len: 64 }]; + // The user's username on Flipcash. Public, so it is returned for any user, + // not just the caller. Unset when the user hasn't claimed one yet. + common.v1.Username username = 8; + // Social profiles are links to external social accounts repeated SocialProfile social_profiles = 2 [(validate.rules).repeated = { min_items: 0 diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/profile_service.proto b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/profile_service.proto index e5e82b010..fbf946b35 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/profile_service.proto +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/profile_service.proto @@ -38,7 +38,14 @@ service Profile { } message GetProfileRequest { - common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + // The user whose profile is being fetched, identified either by their user + // ID or by their username. Exactly one must be set. + oneof identifier { + option (validate.required) = true; + + common.v1.UserId user_id = 1; + common.v1.Username username = 3; + } // Optional auth to retrieve private profile information for self common.v1.Auth auth = 2; diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/resolver/v1/model.proto b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/resolver/v1/model.proto index 48348406b..5c77a739c 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/resolver/v1/model.proto +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/resolver/v1/model.proto @@ -15,8 +15,9 @@ message Identifier { oneof kind { option (validate.required) = true; - common.v1.PhoneNumber phone = 1; - common.v1.UserId user_id = 2; + common.v1.PhoneNumber phone = 1; + common.v1.UserId user_id = 2; + common.v1.Username username = 3; } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift index 288d53380..648e1e6ae 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift @@ -10,8 +10,18 @@ import Foundation extension FlipClient { public func fetchProfile(userID: UserID, owner: KeyPair) async throws -> Profile { + try await fetchProfile(.userID(userID), owner: owner) + } + + /// Fetches the profile behind a claimed handle. The response carries the + /// user's id, so a caller holding only a handle learns it from the result. + public func fetchProfile(username: Username, owner: KeyPair) async throws -> Profile { + try await fetchProfile(.username(username), owner: owner) + } + + private func fetchProfile(_ identifier: ProfileIdentifier, owner: KeyPair) async throws -> Profile { try await withCheckedThrowingContinuation { c in - profileService.fetchProfile(userID: userID, owner: owner) { c.resume(with: $0) } + profileService.fetchProfile(identifier, owner: owner) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Resolver.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Resolver.swift index 0c76b6226..120d6ee68 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Resolver.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Resolver.swift @@ -24,4 +24,13 @@ extension FlipClient { resolverService.resolveUserID(userID, owner: owner) { c.resume(with: $0) } } } + + /// Resolve a claimed handle to the Flipcash payment destination. + /// Throws `.notFound` when the handle is unclaimed; throws for other hard + /// failures. + public func resolveUsername(_ username: Username, owner: KeyPair) async throws -> PublicKey { + try await withCheckedThrowingContinuation { c in + resolverService.resolveUsername(username, owner: owner) { c.resume(with: $0) } + } + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift index 4541c8909..cb89542d1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift @@ -20,11 +20,13 @@ final class ProfileService: Sendable { self.service = Flipcash_Profile_V1_Profile.Client(wrapping: client) } - func fetchProfile(userID: UserID, owner: KeyPair, completion: @Sendable @escaping (Result) -> Void) { - logger.info("Fetching profile", metadata: ["userId": "\(userID)"]) + /// Fetches the profile `identifier` names — by user id, or by the handle the + /// user has claimed. + func fetchProfile(_ identifier: ProfileIdentifier, owner: KeyPair, completion: @Sendable @escaping (Result) -> Void) { + logger.info("Fetching profile", metadata: ["identifier": "\(identifier)"]) let request = Flipcash_Profile_V1_GetProfileRequest.with { - $0.userID = .with { $0.value = userID.data } + $0.identifier = identifier.proto $0.auth = owner.authFor(message: $0) } @@ -47,7 +49,7 @@ final class ProfileService: Sendable { await MainActor.run { completion(.success(.empty)) } } else { - logger.error("Failed to fetch profile", metadata: ["userId": "\(userID)"]) + logger.error("Failed to fetch profile", metadata: ["identifier": "\(identifier)"]) await MainActor.run { completion(.failure(error)) } } } catch let error as RPCError { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ResolverService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ResolverService.swift index 25ff5a0a4..7dd57708a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ResolverService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ResolverService.swift @@ -57,6 +57,26 @@ final class ResolverService: Sendable { resolve(request, completion: completion) } + /// Resolve a claimed handle to the on-chain payment destination. Throws + /// `.notFound` when the handle is unclaimed; throws for hard failures + /// (DENIED, network errors). + func resolveUsername( + _ username: Username, + owner: KeyPair, + completion: @Sendable @escaping (Result) -> Void + ) { + logger.info("Resolving username to payment destination") + + let request = Flipcash_Resolver_V1_ResolveRequest.with { + $0.identifier = .with { + $0.username = .with { $0.value = username.value } + } + $0.auth = owner.authFor(message: $0) + } + + resolve(request, completion: completion) + } + private func resolve( _ request: Flipcash_Resolver_V1_ResolveRequest, completion: @Sendable @escaping (Result) -> Void diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 4daac070e..93c566f5c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -144,16 +144,21 @@ public struct ConversationMember: Hashable, Sendable, Identifiable { /// The blobs backing this member's profile picture, when they have one. /// The renditions authorize through the member's profile access context. public var profilePicture: ProfilePicture? + /// The member's Flipcash handle, when they have claimed one. Carried on the + /// same `profile.v1.UserProfile` the member embeds, so it arrives with the + /// conversation and needs no separate profile fetch. + public var username: Username? public var id: String { userID?.uuidString ?? displayName } - public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil) { + public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil, username: Username? = nil) { self.userID = userID self.displayName = displayName self.phoneE164 = phoneE164 self.readPointer = readPointer self.readPointerTimestamp = readPointerTimestamp self.profilePicture = profilePicture + self.username = username } /// The member's phone number formatted for display, used as a conversation @@ -187,5 +192,8 @@ extension ConversationMember { self.profilePicture = proto.userProfile.hasProfilePicture ? ProfilePicture(proto.userProfile.profilePicture) : nil + self.username = proto.userProfile.hasUsername + ? Username(proto.userProfile.username) + : nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift index 0e83dbf39..b409a3682 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift @@ -30,6 +30,15 @@ public struct Profile: Codable, Equatable, Sendable { /// resolves defaults for anything the user hasn't customized. public let tipCardCustomization: TipCardCustomization? + /// The id of the user this profile belongs to. Server-provided on any + /// fetched profile, so a caller holding only a handle learns the user's id + /// from the response; `nil` on a locally-constructed profile. + public let userID: UserID? + + /// The user's handle on Flipcash, or `nil` when they haven't claimed one. + /// Public — the server returns it for any user, not just the caller. + public let username: Username? + public var isPhoneVerified: Bool { phone != nil } @@ -47,7 +56,7 @@ public struct Profile: Codable, Equatable, Sendable { phone != nil && phone?.e164 != previous?.phone?.e164 } - public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil) throws { + public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil) throws { // Only parse phone if it's not empty var parsedPhone: Phone? @@ -69,17 +78,21 @@ public struct Profile: Codable, Equatable, Sendable { email: normalizedEmail, profilePicture: profilePicture, joinedAt: joinedAt, - tipCardCustomization: tipCardCustomization + tipCardCustomization: tipCardCustomization, + userID: userID, + username: username ) } - public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil) { + public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil) { self.displayName = displayName self.phone = phone self.email = email self.profilePicture = profilePicture self.joinedAt = joinedAt self.tipCardCustomization = tipCardCustomization + self.userID = userID + self.username = username } } @@ -111,7 +124,9 @@ extension Profile { email: proto.emailAddress.value, profilePicture: proto.hasProfilePicture ? ProfilePicture(proto.profilePicture) : nil, joinedAt: proto.hasJoinTs ? proto.joinTs.date : nil, - tipCardCustomization: proto.hasTipCardCustomization ? TipCardCustomization(proto.tipCardCustomization) : nil + tipCardCustomization: proto.hasTipCardCustomization ? TipCardCustomization(proto.tipCardCustomization) : nil, + userID: proto.hasUserID ? try? UUID(data: proto.userID.value) : nil, + username: proto.hasUsername ? Username(proto.username) : nil ) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/ProfileIdentifier.swift b/FlipcashCore/Sources/FlipcashCore/Models/ProfileIdentifier.swift new file mode 100644 index 000000000..a8e05f615 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/ProfileIdentifier.swift @@ -0,0 +1,38 @@ +// +// ProfileIdentifier.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI + +/// Whose profile to fetch. Mirrors the `GetProfileRequest.identifier` oneof: a +/// profile is looked up by exactly one of a user id or a username. +public enum ProfileIdentifier: Equatable, Sendable { + case userID(UserID) + case username(Username) +} + +extension ProfileIdentifier: CustomStringConvertible { + + /// Kind-tagged, so a log line says which arm of the oneof was used. Both + /// arms are public identifiers, so neither is redacted. + public var description: String { + switch self { + case .userID(let userID): "userId:\(userID)" + case .username(let username): "username:\(username)" + } + } +} + +// MARK: - Proto - + +extension ProfileIdentifier { + + var proto: Flipcash_Profile_V1_GetProfileRequest.OneOf_Identifier { + switch self { + case .userID(let userID): .userID(.with { $0.value = userID.data }) + case .username(let username): .username(.with { $0.value = username.value }) + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Username.swift b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift new file mode 100644 index 000000000..e629ae7d9 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift @@ -0,0 +1,70 @@ +// +// Username.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI + +/// A user's unique handle on Flipcash, without a leading `@`. +/// +/// The character set matches X — letters, digits and underscores — except that +/// a handle is always lowercase. Public: the server returns it for any user, +/// not just the caller. +public struct Username: Codable, Equatable, Hashable, Sendable { + + /// The handle as the server stores it: lowercase, 2–15 characters. + public let value: String + + /// Returns the handle `string` names, or `nil` when it isn't well formed. + /// Strict rather than normalizing — an uppercase or over-long input is a + /// caller error, not something to silently rewrite. + public init?(_ string: String) { + guard string.wholeMatch(of: Self.pattern) != nil else { + return nil + } + + self.value = string + } + + // Mirrors the `validate.rules.string.pattern` on `common.v1.Username`; + // keep the two in step when the contract moves. + private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,15}$/ +} + +// MARK: - Codable - + +extension Username { + + /// Encodes as a bare string rather than a wrapper object, so a persisted + /// profile reads as `"username": "ted"`. + public init(from decoder: Decoder) throws { + // Deliberately does not re-validate: the value passed ``init(_:)`` when + // it was first built, and a throw here would fail the decode of the + // whole enclosing profile blob over a cosmetic handle. + self.value = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } +} + +extension Username: CustomStringConvertible { + public var description: String { + value + } +} + +// MARK: - Proto - + +extension Username { + + /// Returns the handle `proto` carries, or `nil` when the user hasn't + /// claimed one — the server leaves the field unset, which decodes to an + /// empty value. + init?(_ proto: Flipcash_Common_V1_Username) { + self.init(proto.value) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 30ba674a6..f6c58c8c1 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -207,6 +207,32 @@ struct ConversationModelMappingTests { #expect(member.profilePicture?.thumbnailBlobID == BlobID(data: thumbnailBlob)) } + /// The handle rides along on the member's embedded profile, so a chat + /// renders it without a separate fetch — the same second mapping site + /// Android carries it through. + @Test("Member maps the handle off its embedded profile") + func memberMapsUsername() { + let proto = Flipcash_Chat_V1_Member.with { + $0.userID = .with { $0.value = UUID().data } + $0.userProfile = .with { + $0.displayName = "Ted" + $0.username = .with { $0.value = "ted_1" } + } + } + + #expect(ConversationMember(proto).username?.value == "ted_1") + } + + @Test("Member has no handle when the profile omits one") + func memberWithoutUsername() { + let proto = Flipcash_Chat_V1_Member.with { + $0.userID = .with { $0.value = UUID().data } + $0.userProfile = .with { $0.displayName = "Ted" } + } + + #expect(ConversationMember(proto).username == nil) + } + @Test("Member has no profile picture when the profile omits one") func memberWithoutProfilePicture() { let proto = Flipcash_Chat_V1_Member.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ProfileIdentifierTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ProfileIdentifierTests.swift new file mode 100644 index 000000000..9f605576c --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ProfileIdentifierTests.swift @@ -0,0 +1,62 @@ +// +// ProfileIdentifierTests.swift +// FlipcashCore +// + +import Foundation +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("ProfileIdentifier Tests") +struct ProfileIdentifierTests { + + /// The wrong arm still builds a valid request, so a swap fails server-side + /// rather than at compile time — pin each one to its oneof case. + @Test("A user id builds the user id arm") + func userIDBuildsItsArm() throws { + let userID = UUID() + + guard case .userID(let proto) = ProfileIdentifier.userID(userID).proto else { + Issue.record("Expected the userID arm") + return + } + + #expect(proto.value == userID.data) + } + + @Test("A handle builds the username arm") + func usernameBuildsItsArm() throws { + let username = try #require(Username("ted_1")) + + guard case .username(let proto) = ProfileIdentifier.username(username).proto else { + Issue.record("Expected the username arm") + return + } + + #expect(proto.value == "ted_1") + } + + /// The arm the request carries decides which lookup the server runs, so a + /// request built from a handle must not also read as a user-id lookup. + @Test("Setting one arm clears the other") + func armsAreMutuallyExclusive() throws { + let username = try #require(Username("ted_1")) + + var request = Flipcash_Profile_V1_GetProfileRequest() + request.identifier = ProfileIdentifier.userID(UUID()).proto + request.identifier = ProfileIdentifier.username(username).proto + + #expect(request.username.value == "ted_1") + #expect(request.userID.value.isEmpty) + } + + @Test("Description tags the arm for logging") + func descriptionTagsTheArm() throws { + let username = try #require(Username("ted_1")) + + #expect("\(ProfileIdentifier.username(username))" == "username:ted_1") + #expect("\(ProfileIdentifier.userID(UUID(uuidString: "da777a11-bd88-4e04-9bf5-173fb4c137a6")!))" + == "userId:DA777A11-BD88-4E04-9BF5-173FB4C137A6") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift index cbc788554..5b00469d0 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift @@ -7,7 +7,8 @@ import Foundation import Testing -import FlipcashCore +import FlipcashAPI +@testable import FlipcashCore @Suite("Profile Tests") struct ProfileTests { @@ -158,6 +159,77 @@ struct ProfileTests { #expect(profile.displayName == "Ted Livingston") #expect(profile.tipCardCustomization == nil) } + + // MARK: - Username - + + @Test("Username round-trips") + func usernameRoundTrips() throws { + let profile = Profile( + displayName: "Ted", + phone: Optional.none, + email: nil, + username: Username("ted_1") + ) + + let restored = try JSONDecoder().decode( + Profile.self, + from: try JSONEncoder().encode(profile) + ) + + #expect(restored.username?.value == "ted_1") + } + + /// Profiles persist as a JSON blob, so `username` is optional and rows + /// written before it still decode — which is why this ships without a + /// `SQLiteVersion` bump. + @Test("A row persisted before usernames still decodes") + func decodesProfilePersistedBeforeUsernames() throws { + let legacy = Data(#"{"displayName":"Ted Livingston","email":"ted@example.com"}"#.utf8) + + let profile = try JSONDecoder().decode(Profile.self, from: legacy) + + #expect(profile.displayName == "Ted Livingston") + #expect(profile.username == nil) + } + + /// The field is public, so it arrives on any profile the client fetches — + /// not just the caller's own. + @Test("A username on the proto maps onto the profile") + func mapsUsernameFromProto() throws { + let proto = Flipcash_Profile_V1_UserProfile.with { + $0.displayName = "Ted" + $0.username = .with { $0.value = "ted_1" } + } + + #expect(try Profile(proto).username?.value == "ted_1") + } + + /// Always set by the server, so a caller that looked the profile up by + /// handle learns the user's id from the response. + @Test("A user id on the proto maps onto the profile") + func mapsUserIDFromProto() throws { + let userID = UUID() + let proto = Flipcash_Profile_V1_UserProfile.with { + $0.displayName = "Ted" + $0.userID = .with { $0.value = userID.data } + } + + #expect(try Profile(proto).userID == userID) + } + + @Test("A profile without a user id maps to nil") + func mapsMissingUserIDToNil() throws { + let proto = Flipcash_Profile_V1_UserProfile.with { $0.displayName = "Ted" } + + #expect(try Profile(proto).userID == nil) + } + + @Test("A profile without a claimed username maps to nil") + func mapsUnclaimedUsernameToNil() throws { + let proto = Flipcash_Profile_V1_UserProfile.with { $0.displayName = "Ted" } + + #expect(try Profile(proto).username == nil) + } } private func makeProfile(email: String? = nil, phone: String? = nil) throws -> Profile { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift new file mode 100644 index 000000000..e93445edd --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift @@ -0,0 +1,78 @@ +// +// UsernameTests.swift +// FlipcashCore +// + +import Foundation +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Username Tests") +struct UsernameTests { + + @Test("Well-formed handles parse", + arguments: [ + "ab", // shortest allowed + "ted", + "ted_1", + "t_9", + "abcdefghijklmno", // longest allowed, 15 chars + ]) + func acceptsWellFormedHandles(input: String) { + #expect(Username(input)?.value == input) + } + + @Test("Malformed handles are rejected", + arguments: [ + "", // unclaimed, carried as an empty proto value + "a", // too short + "abcdefghijklmnop", // 16 chars, too long + "Ted", // uppercase — the server stores lowercase only + "ted bart", // space + "ted-bart", // hyphen is outside the X character set + "ted.bart", + "ted@bart", + "tedé", + " ted", // strict, not trimming + "ted ", + ]) + func rejectsMalformedHandles(input: String) { + #expect(Username(input) == nil) + } + + /// Persisted as a bare string so a profile row reads as `"username": "ted"`, + /// not a nested wrapper object. + @Test("A handle round-trips as a bare JSON string") + func encodesAsABareString() throws { + let username = Username("ted_1")! + + let data = try JSONEncoder().encode(username) + + #expect(String(data: data, encoding: .utf8) == "\"ted_1\"") + #expect(try JSONDecoder().decode(Username.self, from: data) == username) + } + + /// Decoding trusts what was written: re-validating would fail the decode of + /// the whole enclosing profile blob over a cosmetic handle. + @Test("Decoding does not re-validate") + func decodingDoesNotRevalidate() throws { + let data = Data("\"NotAValidHandle\"".utf8) + + #expect(try JSONDecoder().decode(Username.self, from: data).value == "NotAValidHandle") + } + + // MARK: - Proto - + + @Test("An unset proto handle maps to nil") + func unsetProtoMapsToNil() { + #expect(Username(Flipcash_Common_V1_Username()) == nil) + } + + @Test("A set proto handle maps to its value") + func setProtoMapsToItsValue() { + let proto = Flipcash_Common_V1_Username.with { $0.value = "ted_1" } + + #expect(Username(proto)?.value == "ted_1") + } +}