From ae7e45847f06c0db84960c6acf96bb32c5f4c0a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 15:33:32 -0400 Subject: [PATCH 1/2] chore(protos): update flipcash protobuf definitions Adds the Profile service's SetUsername RPC with its request and response messages. Vendored from flipcash2-protobuf-api at HEAD; opencode was already current. --- .../proto/profile/v1/profile_service.proto | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto index fbf946b351..fb9be2f197 100644 --- a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto +++ b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto @@ -17,6 +17,10 @@ service Profile { rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse); + // SetUsername sets the caller's username, replacing any username already + // set. + rpc SetUsername(SetUsernameRequest) returns (SetUsernameResponse); + // SetProfilePicture sets the caller's profile picture to a blob they have // already uploaded via BlobStorage, replacing any picture already set. // @@ -90,6 +94,31 @@ message SetDisplayNameResponse { moderation.v1.FlaggedCategory flagged_category = 2; } +message SetUsernameRequest { + // Username is the new username to set. + common.v1.Username username = 1 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message SetUsernameResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_USERNAME = 1; + DENIED = 2; + ALREADY_TAKEN = 3; + FAILED_MODERATED = 4; + INSUFFICIENT_BALANCE = 5; + RESERVED_WORD = 6; + } + + // The best-fit category that tripped moderation, mirroring the Moderation + // service's vocabulary. Set only when result == FAILED_MODERATED; NONE + // otherwise. + moderation.v1.FlaggedCategory flagged_category = 2; +} + message SetProfilePictureRequest { // The blob holding the ORIGINAL image the caller uploaded. It must be owned // by the caller and READY; the server derives the remaining renditions from From d104bf4f6d45261aa28982123b5add16a06b200f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 15:33:41 -0400 Subject: [PATCH 2/2] feat(flipcash): wire SetUsername through the profile service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Api, Service, Repository, and Controller methods for the new SetUsername RPC, following the setDisplayName chain. On success the controller merges the username into the locally cached profile so observers see it without waiting for a refresh. SetUsernameError covers each non-OK result: InvalidUsername, Denied, AlreadyTaken, FailedModerated (carrying the flagged category), InsufficientBalance, ReservedWord, plus Unrecognized and Other. InternalProfileRepository skips ErrorUtils.handleError for the results that are the server answering a user's choice of handle rather than a fault — InvalidUsername, AlreadyTaken, ReservedWord, FailedModerated, InsufficientBalance. setDisplayName gets the same treatment for InvalidDisplayName and FailedModerated; its only caller, NameEntryViewModel, already renders its own alert for every failure, so this drops a duplicate report path rather than user-facing feedback. --- .../services/controllers/ProfileController.kt | 16 ++++++++++ .../internal/network/api/ProfileApi.kt | 19 ++++++++++++ .../network/services/ProfileService.kt | 26 ++++++++++++++++ .../repositories/InternalProfileRepository.kt | 31 ++++++++++++++++++- .../com/flipcash/services/models/Errors.kt | 17 ++++++++++ .../services/repository/ProfileRepository.kt | 1 + .../controllers/ProfileControllerTest.kt | 2 ++ 7 files changed, 111 insertions(+), 1 deletion(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 9f9a3fa577..6e06f97960 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -98,6 +98,22 @@ class ProfileController @Inject constructor( .onSuccess { mergeLocalProfile { it.copy(displayName = displayName) } } } + /** + * Claims [username] as the caller's public Flipcash handle, replacing any + * username already set. + */ + suspend fun setUsername( + username: String, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setUsername(username, owner) + // Reflect the change locally so anything observing the profile (e.g. a setup flow + // deciding which steps remain) sees it without waiting for a refresh. + .onSuccess { mergeLocalProfile { it.copy(username = username) } } + } + /** * Updates the caller's tip card customization with the given hex color string. */ diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index 9141d5315c..2e54c04ab4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -75,6 +75,25 @@ internal class ProfileApi @Inject constructor( } } + /** + * Sets the username for a user, replacing any username already set. + */ + suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair + ): ProfileService.SetUsernameResponse { + val request = ProfileService.SetUsernameRequest.newBuilder() + .setUsername(username.asUsername()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setUsername(request) + } + } + /** * Sets the caller's profile picture to a blob they have already uploaded via * BlobStorage. The server derives the DISPLAY/THUMBNAIL renditions and returns diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt index 34a1e38030..c07a98080e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.LinkSocialAccountError import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.SetProfilePictureError +import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UnlinkSocialAccountError @@ -63,6 +64,31 @@ internal class ProfileService @Inject constructor( ) } + suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { + api.setUsername(username, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetUsernameResponse.Result.OK -> Result.success(Unit) + ProfileService.SetUsernameResponse.Result.INVALID_USERNAME -> Result.failure(SetUsernameError.InvalidUsername()) + ProfileService.SetUsernameResponse.Result.DENIED -> Result.failure(SetUsernameError.Denied()) + ProfileService.SetUsernameResponse.Result.ALREADY_TAKEN -> Result.failure(SetUsernameError.AlreadyTaken()) + ProfileService.SetUsernameResponse.Result.FAILED_MODERATED -> + Result.failure(SetUsernameError.FailedModerated(response.flaggedCategory.toFlaggedCategory())) + ProfileService.SetUsernameResponse.Result.INSUFFICIENT_BALANCE -> Result.failure(SetUsernameError.InsufficientBalance()) + ProfileService.SetUsernameResponse.Result.RESERVED_WORD -> Result.failure(SetUsernameError.ReservedWord()) + ProfileService.SetUsernameResponse.Result.UNRECOGNIZED -> Result.failure(SetUsernameError.Unrecognized()) + null -> Result.failure(SetUsernameError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetUsernameError.Other(cause) }) } + ) + } + suspend fun setProfilePicture( blobId: BlobId, owner: Ed25519.KeyPair, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt index 0898f23fc3..ad34e50a2e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt @@ -5,6 +5,8 @@ import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.ProfileIdentifier +import com.flipcash.services.models.SetDisplayNameError +import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -38,7 +40,34 @@ internal class InternalProfileRepository( owner: Ed25519.KeyPair ): Result { return service.setDisplayName(displayName, owner) - .onFailure { ErrorUtils.handleError(it) } + .onFailure { + // The rejections below are the server answering a user's choice of + // display name, not a fault worth reporting. + val expected = it is SetDisplayNameError.InvalidDisplayName || + it is SetDisplayNameError.FailedModerated + if (!expected) { + ErrorUtils.handleError(it) + } + } + } + + override suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair + ): Result { + return service.setUsername(username, owner) + .onFailure { + // The rejections below are the server answering a user's choice of + // username, not a fault worth reporting. + val expected = it is SetUsernameError.InvalidUsername || + it is SetUsernameError.AlreadyTaken || + it is SetUsernameError.ReservedWord || + it is SetUsernameError.FailedModerated || + it is SetUsernameError.InsufficientBalance + if (!expected) { + ErrorUtils.handleError(it) + } + } } override suspend fun setProfilePicture( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index f157e1d0d8..c956ae44dd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -206,6 +206,23 @@ sealed class SetDisplayNameError( data class Other(override val cause: Throwable? = null) : SetDisplayNameError(message = cause?.message, cause = cause), NotifiableError } +sealed class SetUsernameError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class InvalidUsername: SetUsernameError("Invalid username") + class Denied: SetUsernameError("Denied") + // Another user already holds this username. + class AlreadyTaken: SetUsernameError("Username already taken") + class FailedModerated(val category: ModerationResult.FlaggedCategory) : SetUsernameError("Content flagged: $category") + // Claiming this username costs more than the caller can pay. + class InsufficientBalance: SetUsernameError("Insufficient balance") + // The username is on the server's reserved list and cannot be claimed. + class ReservedWord: SetUsernameError("Reserved word") + class Unrecognized : SetUsernameError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetUsernameError(message = cause?.message, cause = cause), NotifiableError +} + sealed class SetProfilePictureError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt index 33efbc44f8..257ac6a08d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt @@ -12,6 +12,7 @@ import com.getcode.ed25519.Ed25519 interface ProfileRepository { suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result + suspend fun setUsername(username: String, owner: Ed25519.KeyPair): Result suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index 5f8b007bda..d141e37794 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -302,6 +302,7 @@ class ProfileControllerTest { private class FakeProfileRepository : ProfileRepository { var getProfileResult: Result = Result.failure(RuntimeException("not configured")) var setDisplayNameResult: Result = Result.success(Unit) + var setUsernameResult: Result = Result.success(Unit) var setProfilePictureResult: Result = Result.failure(RuntimeException("not configured")) var updateTipCardResult: Result = Result.success(Unit) var linkSocialAccountResult: Result = Result.failure(RuntimeException("not configured")) @@ -309,6 +310,7 @@ private class FakeProfileRepository : ProfileRepository { override suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair) = getProfileResult override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult + override suspend fun setUsername(username: String, owner: Ed25519.KeyPair) = setUsernameResult override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) =