Skip to content

Commit 4fa7cdc

Browse files
authored
feat(flipcash): wire usernames through profile and resolver (#1316)
Refresh the flipcash protos, which introduce a public `Username` handle: - common/v1 gains a `Username` message (lowercase `^[a-z0-9_]{2,15}$`) - `UserProfile` carries `user_id` and `username` - `GetProfileRequest.user_id` becomes a `oneof identifier` of user ID or username - the resolver `Identifier` oneof gains `username` Wire all of it through the service layer: - `UserProfile` gains `userId`/`username`, mapped in `UserProfileMapper` and in the chat-member profile path, which decodes the same proto message. Both are trailing and default to null so the positional constructor and previously persisted profile JSON keep decoding. - New `ProfileIdentifier` sealed interface mirrors the `GetProfileRequest` oneof and is threaded through `ProfileApi` -> `ProfileService` -> `ProfileRepository`. `ProfileController.getProfileForUser` is unchanged for callers; lookup by handle is `getProfileForUsername`. - `ResolveIdentifier.Username` plus `ResolverController.resolve(username)`. `ProfileApi.getProfile` now validates its request like every other method in the class, so a malformed handle fails locally instead of round-tripping: the oneof carries `validate.required` and the username its character-set pattern. The opencode protos were already up to date.
1 parent 57c991c commit 4fa7cdc

18 files changed

Lines changed: 108 additions & 19 deletions

File tree

definitions/flipcash/protos/src/main/proto/common/v1/common.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ message UserId {
6161
}];
6262
}
6363

64+
// Username is a user's unique handle on Flipcash. It uses the same character
65+
// set as X — letters, digits and underscores — with the exception that it must
66+
// be lowercase.
67+
message Username {
68+
string value = 1 [(validate.rules).string.pattern = "^[a-z0-9_]{2,15}$"];
69+
}
70+
6471
message ChatId {
6572
// value has the following structure:
6673
// - 32 byte hash for DMs

definitions/flipcash/protos/src/main/proto/profile/v1/model.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ import "google/protobuf/timestamp.proto";
1212
import "validate/validate.proto";
1313

1414
message UserProfile {
15+
// The ID of the user this profile belongs to. Always set, so a caller that
16+
// looked the profile up by username learns the user's ID from the response.
17+
common.v1.UserId user_id = 9 [(validate.rules).message.required = true];
18+
1519
// Display name is the display name of the user (if found).
1620
string display_name = 1 [(validate.rules).string = {
1721
min_len: 0
1822
max_len: 64
1923
}];
2024

25+
// The user's username on Flipcash. Public, so it is returned for any user,
26+
// not just the caller. Unset when the user hasn't claimed one yet.
27+
common.v1.Username username = 8;
28+
2129
// Social profiles are links to external social accounts
2230
repeated SocialProfile social_profiles = 2 [(validate.rules).repeated = {
2331
min_items: 0

definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ service Profile {
3838
}
3939

4040
message GetProfileRequest {
41-
common.v1.UserId user_id = 1 [(validate.rules).message.required = true];
41+
// The user whose profile is being fetched, identified either by their user
42+
// ID or by their username. Exactly one must be set.
43+
oneof identifier {
44+
option (validate.required) = true;
45+
46+
common.v1.UserId user_id = 1;
47+
common.v1.Username username = 3;
48+
}
4249

4350
// Optional auth to retrieve private profile information for self
4451
common.v1.Auth auth = 2;

definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ message Identifier {
1515
oneof kind {
1616
option (validate.required) = true;
1717

18-
common.v1.PhoneNumber phone = 1;
19-
common.v1.UserId user_id = 2;
18+
common.v1.PhoneNumber phone = 1;
19+
common.v1.UserId user_id = 2;
20+
common.v1.Username username = 3;
2021
}
2122
}
2223

services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.flipcash.services.controllers
22

33
import com.flipcash.services.models.GetUserProfileError
44
import com.flipcash.services.models.LinkingToken
5+
import com.flipcash.services.models.ProfileIdentifier
56
import com.flipcash.services.models.SocialAccount
67
import com.flipcash.services.models.SocialAccountLinkRequest
78
import com.flipcash.services.models.SocialAccountUnlinkRequest
@@ -68,11 +69,21 @@ class ProfileController @Inject constructor(
6869

6970
suspend fun getProfileForUser(
7071
userId: ID,
71-
): Result<UserProfile> {
72+
): Result<UserProfile> = getProfile(ProfileIdentifier.UserId(userId))
73+
74+
/**
75+
* Fetches a profile by its owner's public Flipcash handle. The response carries
76+
* the user's ID, so a caller that only had the username learns it from here.
77+
*/
78+
suspend fun getProfileForUsername(
79+
username: String,
80+
): Result<UserProfile> = getProfile(ProfileIdentifier.Username(username))
81+
82+
private suspend fun getProfile(identifier: ProfileIdentifier): Result<UserProfile> {
7283
val owner = userManager.accountCluster?.authority?.keyPair
7384
?: return Result.failure(Throwable("No account cluster in UserManager"))
7485

75-
return repository.getProfile(userId, owner)
86+
return repository.getProfile(identifier, owner)
7687
}
7788

7889
suspend fun setDisplayName(

services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class ResolverController @Inject constructor(
2222
suspend fun resolve(userId: ID): Result<PublicKey> =
2323
resolve(ResolveIdentifier.UserId(userId))
2424

25+
/** Resolves a username to its owner's on-chain address. */
26+
suspend fun resolve(username: String): Result<PublicKey> =
27+
resolve(ResolveIdentifier.Username(username))
28+
2529
private suspend fun resolve(identifier: ResolveIdentifier): Result<PublicKey> {
2630
val owner = userManager.accountCluster?.authority?.keyPair
2731
?: return Result.failure(Throwable("No account cluster in UserManager"))

services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.flipcash.services.internal.domain
33
import com.codeinc.flipcash.gen.profile.v1.Model
44
import com.codeinc.flipcash.gen.profile.v1.emailAddressOrNull
55
import com.codeinc.flipcash.gen.profile.v1.phoneNumberOrNull
6+
import com.codeinc.flipcash.gen.profile.v1.usernameOrNull
7+
import com.flipcash.services.internal.network.extensions.toId
68
import com.flipcash.services.internal.network.extensions.toMediaItem
79
import com.flipcash.services.models.UserProfile
810
import com.flipcash.services.models.VerifiableContactMethod
@@ -25,6 +27,9 @@ class UserProfileMapper @Inject constructor(
2527
Instant.fromEpochSeconds(from.joinTs.seconds, from.joinTs.nanos)
2628
} else null,
2729
tipCardColor = if (from.hasTipCardCustomization()) from.tipCardCustomization.color.hex else null,
30+
userId = if (from.hasUserId()) from.userId.toId() else null,
31+
// Public, so it is returned for any user — absent only when unclaimed.
32+
username = from.usernameOrNull?.value,
2833
)
2934
}
3035
}

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import com.codeinc.flipcash.gen.profile.v1.ProfileGrpcKt
55
import com.codeinc.flipcash.gen.profile.v1.ProfileService
66
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
77
import com.flipcash.services.internal.network.extensions.asUserId
8+
import com.flipcash.services.internal.network.extensions.asUsername
89
import com.flipcash.services.internal.network.extensions.authenticate
910
import com.flipcash.services.internal.network.extensions.linkingToken
11+
import com.flipcash.services.models.ProfileIdentifier
1012
import com.flipcash.services.models.SocialAccountLinkRequest
1113
import com.flipcash.services.models.SocialAccountUnlinkRequest
1214
import com.flipcash.services.models.chat.BlobId
1315
import com.getcode.ed25519.Ed25519
1416
import com.getcode.opencode.internal.network.core.GrpcApi
15-
import com.getcode.opencode.model.core.ID
1617
import com.getcode.utils.toByteString
1718
import com.codeinc.flipcash.gen.profile.v1.validate
1819
import dev.bmcreations.protovalidate.orThrow
@@ -32,14 +33,24 @@ internal class ProfileApi @Inject constructor(
3233
.withWaitForReady()
3334

3435
/**
35-
* Gets the profile for a user
36+
* Gets the profile for a user, keyed by either their user ID or their username.
3637
*/
37-
suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): ProfileService.GetProfileResponse {
38+
suspend fun getProfile(
39+
identifier: ProfileIdentifier,
40+
owner: Ed25519.KeyPair,
41+
): ProfileService.GetProfileResponse {
3842
val request = ProfileService.GetProfileRequest.newBuilder()
39-
.setUserId(userId.asUserId())
43+
.apply {
44+
when (identifier) {
45+
is ProfileIdentifier.UserId -> setUserId(identifier.userId.asUserId())
46+
is ProfileIdentifier.Username -> setUsername(identifier.username.asUsername())
47+
}
48+
}
4049
.apply { setAuth(authenticate(owner)) }
4150
.build()
4251

52+
request.validate().orThrow()
53+
4354
return withContext(Dispatchers.IO) {
4455
api.getProfile(request)
4556
}

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.flipcash.services.models.ResolveIdentifier
77
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
88
import com.flipcash.services.internal.network.extensions.asUserId
99
import com.flipcash.services.internal.network.extensions.authenticate
10+
import com.flipcash.services.internal.network.extensions.asUsername
1011
import com.getcode.ed25519.Ed25519.KeyPair
1112
import com.getcode.opencode.internal.network.core.GrpcApi
1213
import dev.bmcreations.protovalidate.orThrow
@@ -50,6 +51,8 @@ internal class ResolverApi @Inject constructor(
5051
builder.setPhone(Common.PhoneNumber.newBuilder().setValue(phone.phoneNumber))
5152
is ResolveIdentifier.UserId ->
5253
builder.setUserId(userId.asUserId())
54+
is ResolveIdentifier.Username ->
55+
builder.setUsername(username.asUsername())
5356
}.build()
5457
}
5558
}

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ internal fun ID.asUserId(): Common.UserId {
4545
return Common.UserId.newBuilder().setValue(toByteString()).build()
4646
}
4747

48+
internal fun String.asUsername(): Common.Username {
49+
return Common.Username.newBuilder().setValue(this).build()
50+
}
51+
4852
internal fun Instant.asTimestamp(): Timestamp {
4953
return Timestamp.newBuilder().setSeconds(this.epochSeconds).build()
5054
}

0 commit comments

Comments
 (0)