From 5bcc221b49b3430b9d541bb66bb17a226f25d89b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 14:31:07 -0400 Subject: [PATCH 1/2] feat(chat): scaffold group invite RPCs and models Wrap CreateInvite, GetInvite, GetChatInvites and RevokeInvite through api, service, repository and controller. JoinChat takes optional invite parameters and maps INVITE_NOT_FOUND/EXHAUSTED/EXPIRED/REVOKED. GetRoster accepts an invite token with the owner optional. GetKeyEnvelope now branches on envelopeCase: an invite key bundle fails with GetKeyEnvelopeError.InviteKeyBundleOnly instead of reading a default KeyEnvelope, and wrapped_by_public_key is carried on StoredKeyEnvelope. Adds Invite domain models, ViewerState.Permissions.canInvite, and the invited join system event (persisted alongside admittedBy). Invite crypto and UI are not included. --- .../converters/ChatTypeConverters.kt | 3 +- .../sources/mapper/chat/ChatEntityMapper.kt | 3 +- .../services/controllers/ChatController.kt | 59 ++++++- .../services/internal/network/api/ChatApi.kt | 108 ++++++++++++- .../network/extensions/InviteMappers.kt | 83 ++++++++++ .../network/extensions/LocalToProtobuf.kt | 12 +- .../network/extensions/ProtobufToLocal.kt | 4 + .../internal/network/services/ChatService.kt | 120 +++++++++++++- .../repositories/InternalChatRepository.kt | 89 ++++++++++- .../com/flipcash/services/models/Errors.kt | 56 +++++++ .../flipcash/services/models/chat/Invite.kt | 150 ++++++++++++++++++ .../services/models/chat/KeyEnvelope.kt | 6 +- .../services/models/chat/MessageContent.kt | 8 +- .../services/models/chat/ViewerState.kt | 3 + .../services/repository/ChatRepository.kt | 64 +++++++- .../controllers/ChatControllerTest.kt | 35 +++- 16 files changed, 775 insertions(+), 28 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/InviteMappers.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Invite.kt diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 55555fd3f1..b9274a8741 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -260,7 +260,7 @@ sealed interface MessageContentSerialized { data class WidgetPaymentRequest(val paymentRequestId: String) : MessageContentSerialized // Mirrors MessageContent.System. [joinedUser] is set for a UserJoined event, and - // [admittedBy] when someone let them in; both hex-encoded. Rows stored before the event was + // [admittedBy] when someone let them in or [invitedBy] when they used someone's invite; each hex-encoded. Rows stored before the event was // kept have neither and decode as fallback-only. @Serializable @SerialName("system") @@ -268,6 +268,7 @@ sealed interface MessageContentSerialized { val fallbackText: String, val joinedUser: String? = null, val admittedBy: String? = null, + val invitedBy: String? = null, ) : MessageContentSerialized // Mirrors MessageContent.Unsupported: a content type the build that stored it didn't know. diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 4538e42a42..6fe287176f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -430,6 +430,7 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this fallbackText = fallbackText, joinedUser = event.userId.hexEncodedString(), admittedBy = event.admittedBy?.hexEncodedString(), + invitedBy = event.invitedBy?.hexEncodedString(), ) null -> MessageContentSerialized.System(fallbackText = fallbackText) } @@ -485,7 +486,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { ) is MessageContentSerialized.System -> MessageContent.System( fallbackText = fallbackText, - event = joinedUser?.let { MessageContent.SystemEvent.UserJoined(it.hexToIdExt(), admittedBy?.hexToIdExt()) }, + event = joinedUser?.let { MessageContent.SystemEvent.UserJoined(it.hexToIdExt(), admittedBy?.hexToIdExt(), invitedBy?.hexToIdExt()) }, ) MessageContentSerialized.Unsupported -> MessageContent.Unsupported is MessageContentSerialized.Deleted -> MessageContent.Deleted( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index d5a5924cbc..8a7d7af54d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -8,6 +8,14 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InvitePreview +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage @@ -25,6 +33,7 @@ import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Instant @Singleton class ChatController @Inject constructor( @@ -86,11 +95,15 @@ class ChatController @Inject constructor( suspend fun getRoster( chatId: ChatId, queryOptions: QueryOptions = QueryOptions(), + inviteToken: InviteToken? = null, ): Result { + // With an invite token the account is optional: the token stands in for auth. val owner = userManager.accountCluster?.authority?.keyPair - ?: return Result.failure(Throwable("No account cluster in UserManager")) + if (owner == null && inviteToken == null) { + return Result.failure(Throwable("No account cluster in UserManager")) + } - return repository.getRoster(owner, chatId, queryOptions) + return repository.getRoster(owner, chatId, queryOptions, inviteToken) } /** @@ -135,11 +148,12 @@ class ChatController @Inject constructor( return repository.editChat(owner, chatId, parameters) } - suspend fun joinChat(chatId: ChatId): Result { + /** Joins [chatId], with [invite] when joining a private group through a link. */ + suspend fun joinChat(chatId: ChatId, invite: InviteParameters? = null): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.joinChat(owner, chatId) + return repository.joinChat(owner, chatId, invite) } suspend fun leaveChat(chatId: ChatId): Result { @@ -267,4 +281,41 @@ class ChatController @Inject constructor( return repository.getKeyEnvelope(owner, chatId) } + + /** Creates an invite link for private group [chatId]; see [ChatRepository.createInvite]. */ + suspend fun createInvite( + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int = 0, + expiresAt: Instant? = null, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.createInvite(owner, chatId, token, keyEnvelope, secretEnvelope, maxUses, expiresAt) + } + + /** What [token]'s link leads to. Works without an account cluster: an anonymous viewer can look an invite up. */ + suspend fun getInvite(token: InviteToken): Result { + val owner = userManager.accountCluster?.authority?.keyPair + return repository.getInvite(owner, token) + } + + /** One page of [chatId]'s invites. */ + suspend fun getChatInvites(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getChatInvites(owner, chatId, queryOptions) + } + + /** Revokes [inviteId] on [chatId]. */ + suspend fun revokeInvite(chatId: ChatId, inviteId: InviteId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.revokeInvite(owner, chatId, inviteId) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index d74b15759b..f571d10b58 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -12,6 +12,12 @@ import com.flipcash.services.internal.network.extensions.asProtoSilenceState import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope +import com.flipcash.services.internal.network.extensions.asProtoInviteId +import com.flipcash.services.internal.network.extensions.asProtoInviteKeyEnvelope +import com.flipcash.services.internal.network.extensions.asProtoInviteParameters +import com.flipcash.services.internal.network.extensions.asProtoInviteSecretEnvelope +import com.flipcash.services.internal.network.extensions.asProtoInviteToken +import com.flipcash.services.internal.network.extensions.asTimestamp import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.asUsername import com.flipcash.services.internal.network.extensions.asViewMode @@ -22,12 +28,18 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DescriptionEdit import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewMode import com.getcode.utils.toByteString +import kotlin.time.Instant import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID import com.getcode.opencode.internal.network.core.GrpcApi @@ -168,15 +180,22 @@ internal class ChatApi @Inject constructor( } } + /** + * [owner] is optional when [inviteToken] is set: the token stands in for auth, so a viewer + * with an invite link can read the roster before joining. Without a token, [owner] is required. + */ suspend fun getRoster( - owner: KeyPair, + owner: KeyPair?, chatId: ChatId, queryOptions: QueryOptions = QueryOptions(), + inviteToken: InviteToken? = null, ): RpcChatService.GetRosterResponse { + require(owner != null || inviteToken != null) { "getRoster needs an owner or an invite token" } val request = RpcChatService.GetRosterRequest.newBuilder() .setChatId(chatId.asChatId()) .setQueryOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } + .apply { inviteToken?.let { setInviteToken(it.asProtoInviteToken()) } } + .apply { owner?.let { setAuth(authenticate(it)) } } .build() request.validate().orThrow() @@ -312,9 +331,11 @@ internal class ChatApi @Inject constructor( suspend fun joinChat( owner: KeyPair, chatId: ChatId, + invite: InviteParameters? = null, ): RpcChatService.JoinChatResponse { val request = RpcChatService.JoinChatRequest.newBuilder() .setChatId(chatId.asChatId()) + .apply { invite?.let { setInvite(it.asProtoInviteParameters()) } } .apply { setAuth(authenticate(owner)) } .build() @@ -588,4 +609,87 @@ internal class ChatApi @Inject constructor( api.getKeyEnvelope(request) } } + + /** + * Idempotent on [token]. [maxUses] 0 means unlimited; [expiresAt] null means it never expires. + * The envelopes are produced by invite crypto this layer does not do. + */ + suspend fun createInvite( + owner: KeyPair, + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int = 0, + expiresAt: Instant? = null, + ): RpcChatService.CreateInviteResponse { + val request = RpcChatService.CreateInviteRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setInviteToken(token.asProtoInviteToken()) + .setKeyEnvelope(keyEnvelope.asProtoInviteKeyEnvelope()) + .setSecretEnvelope(secretEnvelope.asProtoInviteSecretEnvelope()) + .setMaxUses(maxUses) + .apply { expiresAt?.let { setExpiresAt(it.asTimestamp()) } } + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.createInvite(request) + } + } + + /** [owner] is optional: an anonymous viewer can look an invite up. */ + suspend fun getInvite( + owner: KeyPair?, + token: InviteToken, + ): RpcChatService.GetInviteResponse { + val request = RpcChatService.GetInviteRequest.newBuilder() + .setInviteToken(token.asProtoInviteToken()) + .apply { owner?.let { setAuth(authenticate(it)) } } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getInvite(request) + } + } + + suspend fun getChatInvites( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): RpcChatService.GetChatInvitesResponse { + val request = RpcChatService.GetChatInvitesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getChatInvites(request) + } + } + + suspend fun revokeInvite( + owner: KeyPair, + chatId: ChatId, + inviteId: InviteId, + ): RpcChatService.RevokeInviteResponse { + val request = RpcChatService.RevokeInviteRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setInviteId(inviteId.asProtoInviteId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.revokeInvite(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/InviteMappers.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/InviteMappers.kt new file mode 100644 index 0000000000..1c8b3e794e --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/InviteMappers.kt @@ -0,0 +1,83 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyBundle +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken +import com.getcode.utils.toByteString +import kotlin.time.Instant + +internal fun InviteId.asProtoInviteId(): ChatModel.InviteId = + ChatModel.InviteId.newBuilder().setValue(bytes.toByteString()).build() + +internal fun InviteToken.asProtoInviteToken(): ChatModel.InviteToken = + ChatModel.InviteToken.newBuilder().setValue(bytes.toByteString()).build() + +internal fun InviteKeyEnvelope.asProtoInviteKeyEnvelope(): ChatModel.InviteKeyEnvelope = + ChatModel.InviteKeyEnvelope.newBuilder() + .setSchemeValue(scheme) + .setNonce(nonce.toByteString()) + .setCiphertext(ciphertext.toByteString()) + .setSignature(signature.toByteString()) + .build() + +internal fun InviteSecretEnvelope.asProtoInviteSecretEnvelope(): ChatModel.InviteSecretEnvelope = + ChatModel.InviteSecretEnvelope.newBuilder() + .setSchemeValue(scheme) + .setNonce(nonce.toByteString()) + .setCiphertext(ciphertext.toByteString()) + .build() + +internal fun InviteParameters.asProtoInviteParameters(): RpcChatService.JoinChatRequest.InviteParameters = + RpcChatService.JoinChatRequest.InviteParameters.newBuilder() + .setInviteToken(token.asProtoInviteToken()) + .setSecretEnvelope(secretEnvelope.asProtoInviteSecretEnvelope()) + .build() + +internal fun ChatModel.InviteId.toInviteId(): InviteId = InviteId(value.toByteArray()) + +internal fun ChatModel.InviteKeyEnvelope.toInviteKeyEnvelope(): InviteKeyEnvelope = InviteKeyEnvelope( + scheme = schemeValue, + nonce = nonce.toByteArray(), + ciphertext = ciphertext.toByteArray(), + signature = signature.toByteArray(), +) + +internal fun ChatModel.InviteSecretEnvelope.toInviteSecretEnvelope(): InviteSecretEnvelope = InviteSecretEnvelope( + scheme = schemeValue, + nonce = nonce.toByteArray(), + ciphertext = ciphertext.toByteArray(), +) + +internal fun ChatModel.Invite.Status.toInviteStatus(): Invite.Status = when (this) { + ChatModel.Invite.Status.ACTIVE -> Invite.Status.ACTIVE + ChatModel.Invite.Status.EXHAUSTED -> Invite.Status.EXHAUSTED + ChatModel.Invite.Status.EXPIRED -> Invite.Status.EXPIRED + ChatModel.Invite.Status.REVOKED -> Invite.Status.REVOKED + else -> Invite.Status.UNKNOWN +} + +internal fun ChatModel.Invite.toInvite(): Invite = Invite( + id = inviteId.toInviteId(), + chatId = chatId.toChatId(), + createdBy = createdBy.toId(), + maxUses = Integer.toUnsignedLong(maxUses), + useCount = Integer.toUnsignedLong(useCount), + createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanos), + expiresAt = if (hasExpiresAt()) Instant.fromEpochSeconds(expiresAt.seconds, expiresAt.nanos) else null, + status = status.toInviteStatus(), + secretEnvelope = if (hasSecretEnvelope()) secretEnvelope.toInviteSecretEnvelope() else null, +) + +internal fun RpcChatService.GetKeyEnvelopeResponse.toInviteKeyBundle(): InviteKeyBundle = InviteKeyBundle( + inviteId = inviteKeyBundle.inviteId.toInviteId(), + keyEnvelope = inviteKeyBundle.keyEnvelope.toInviteKeyEnvelope(), + secretEnvelope = inviteKeyBundle.secretEnvelope.toInviteSecretEnvelope(), + wrappedBy = if (hasWrappedBy()) wrappedBy.toId() else null, + wrappedByPublicKey = if (hasWrappedByPublicKey()) wrappedByPublicKey.toPublicKey() else null, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index bfc91daa44..77d29e7b8f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -217,9 +217,15 @@ internal fun MessageContent.asContent(): MessagingModel.Content { is MessageContent.SystemEvent.UserJoined -> { val joined = MessagingModel.SystemContent.UserJoined.newBuilder() .setUserId(event.userId.asUserId()) - event.admittedBy?.let { - joined.setAdmitted(MessagingModel.SystemContent.UserJoined.Admitted.newBuilder().setAdmittedBy(it.asUserId())) - } ?: joined.setDirect(MessagingModel.SystemContent.UserJoined.Direct.getDefaultInstance()) + val admittedBy = event.admittedBy + val invitedBy = event.invitedBy + when { + admittedBy != null -> + joined.setAdmitted(MessagingModel.SystemContent.UserJoined.Admitted.newBuilder().setAdmittedBy(admittedBy.asUserId())) + invitedBy != null -> + joined.setInvited(MessagingModel.SystemContent.UserJoined.Invited.newBuilder().setInvitedBy(invitedBy.asUserId())) + else -> joined.setDirect(MessagingModel.SystemContent.UserJoined.Direct.getDefaultInstance()) + } systemBuilder.setUserJoined(joined) } null -> Unit diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index e37b7f7e09..4a3b166e1b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -518,6 +518,7 @@ internal fun ChatModel.ViewerState.toViewerState(): ViewerState { permissions = if (hasPermissions()) { ViewerState.Permissions( canEdit = permissions.canEdit, + canInvite = permissions.canInvite, moderation = if (permissions.hasModeration()) { ViewerState.ModerationPermissions( canSilence = permissions.moderation.canSilence, @@ -850,6 +851,9 @@ private fun MessagingModel.SystemContent.toSystemEvent(): MessageContent.SystemE admittedBy = joined.admitted .takeIf { joined.methodCase == MessagingModel.SystemContent.UserJoined.MethodCase.ADMITTED && it.hasAdmittedBy() } ?.admittedBy?.toId(), + invitedBy = joined.invited + .takeIf { joined.methodCase == MessagingModel.SystemContent.UserJoined.MethodCase.INVITED && it.hasInvitedBy() } + ?.invitedBy?.toId(), ) } else -> null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index c3d11b9bb6..dbbccd24d1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -21,6 +21,10 @@ import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError import com.flipcash.services.models.GetMentionSuggestionsError +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.GetInviteError +import com.flipcash.services.models.RevokeInviteError import com.flipcash.services.models.GetRosterError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError @@ -34,6 +38,11 @@ import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.SilenceState @@ -45,6 +54,7 @@ import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.model.core.ID import com.getcode.opencode.utils.toValidationOrElse import javax.inject.Inject +import kotlin.time.Instant internal class ChatService @Inject constructor( private val api: ChatApi, @@ -118,12 +128,13 @@ internal class ChatService @Inject constructor( } suspend fun getRoster( - owner: KeyPair, + owner: KeyPair?, chatId: ChatId, queryOptions: QueryOptions, + inviteToken: InviteToken? = null, ): Result { return runCatching { - api.getRoster(owner, chatId, queryOptions) + api.getRoster(owner, chatId, queryOptions, inviteToken) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -230,9 +241,10 @@ internal class ChatService @Inject constructor( suspend fun joinChat( owner: KeyPair, chatId: ChatId, + invite: InviteParameters? = null, ): Result { return runCatching { - api.joinChat(owner, chatId) + api.joinChat(owner, chatId, invite) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -241,6 +253,10 @@ internal class ChatService @Inject constructor( RpcChatService.JoinChatResponse.Result.NOT_FOUND -> Result.failure(JoinChatError.NotFound()) RpcChatService.JoinChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(JoinChatError.RulesNotSatisfied()) RpcChatService.JoinChatResponse.Result.BANNED -> Result.failure(JoinChatError.Banned()) + RpcChatService.JoinChatResponse.Result.INVITE_NOT_FOUND -> Result.failure(JoinChatError.InviteNotFound()) + RpcChatService.JoinChatResponse.Result.INVITE_EXHAUSTED -> Result.failure(JoinChatError.InviteExhausted()) + RpcChatService.JoinChatResponse.Result.INVITE_EXPIRED -> Result.failure(JoinChatError.InviteExpired()) + RpcChatService.JoinChatResponse.Result.INVITE_REVOKED -> Result.failure(JoinChatError.InviteRevoked()) RpcChatService.JoinChatResponse.Result.UNRECOGNIZED -> Result.failure(JoinChatError.Unrecognized()) else -> Result.failure(JoinChatError.Other()) } @@ -660,4 +676,102 @@ internal class ChatService @Inject constructor( } ) } + + suspend fun createInvite( + owner: KeyPair, + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int, + expiresAt: Instant?, + ): Result { + return runCatching { + api.createInvite(owner, chatId, token, keyEnvelope, secretEnvelope, maxUses, expiresAt) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.CreateInviteResponse.Result.OK -> Result.success(response.invite) + RpcChatService.CreateInviteResponse.Result.DENIED -> Result.failure(CreateInviteError.Denied()) + RpcChatService.CreateInviteResponse.Result.NOT_FOUND -> Result.failure(CreateInviteError.NotFound()) + RpcChatService.CreateInviteResponse.Result.INVALID_EXPIRES_AT -> Result.failure(CreateInviteError.InvalidExpiresAt()) + RpcChatService.CreateInviteResponse.Result.TOO_MANY_INVITES -> Result.failure(CreateInviteError.TooManyInvites()) + RpcChatService.CreateInviteResponse.Result.INVALID_SIGNATURE -> Result.failure(CreateInviteError.InvalidSignature()) + RpcChatService.CreateInviteResponse.Result.UNRECOGNIZED -> Result.failure(CreateInviteError.Unrecognized()) + else -> Result.failure(CreateInviteError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { CreateInviteError.Other(cause = it) }) + } + ) + } + + suspend fun getInvite( + owner: KeyPair?, + token: InviteToken, + ): Result { + return runCatching { + api.getInvite(owner, token) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetInviteResponse.Result.OK -> Result.success(response) + RpcChatService.GetInviteResponse.Result.NOT_FOUND -> Result.failure(GetInviteError.NotFound()) + RpcChatService.GetInviteResponse.Result.BANNED -> Result.failure(GetInviteError.Banned()) + RpcChatService.GetInviteResponse.Result.UNRECOGNIZED -> Result.failure(GetInviteError.Unrecognized()) + else -> Result.failure(GetInviteError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetInviteError.Other(cause = it) }) + } + ) + } + + suspend fun getChatInvites( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getChatInvites(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetChatInvitesResponse.Result.OK -> Result.success(response) + RpcChatService.GetChatInvitesResponse.Result.DENIED -> Result.failure(GetChatInvitesError.Denied()) + RpcChatService.GetChatInvitesResponse.Result.NOT_FOUND -> Result.failure(GetChatInvitesError.NotFound()) + RpcChatService.GetChatInvitesResponse.Result.UNRECOGNIZED -> Result.failure(GetChatInvitesError.Unrecognized()) + else -> Result.failure(GetChatInvitesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetChatInvitesError.Other(cause = it) }) + } + ) + } + + suspend fun revokeInvite( + owner: KeyPair, + chatId: ChatId, + inviteId: InviteId, + ): Result { + return runCatching { + api.revokeInvite(owner, chatId, inviteId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.RevokeInviteResponse.Result.OK -> Result.success(Unit) + RpcChatService.RevokeInviteResponse.Result.DENIED -> Result.failure(RevokeInviteError.Denied()) + RpcChatService.RevokeInviteResponse.Result.NOT_FOUND -> Result.failure(RevokeInviteError.NotFound()) + RpcChatService.RevokeInviteResponse.Result.UNRECOGNIZED -> Result.failure(RevokeInviteError.Unrecognized()) + else -> Result.failure(RevokeInviteError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { RevokeInviteError.Other(cause = it) }) + } + ) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index c63b896580..1c1e3042fd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -4,13 +4,22 @@ import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.extensions.toChatMember import com.flipcash.services.internal.network.extensions.toId +import com.flipcash.services.internal.network.extensions.toInvite +import com.flipcash.services.internal.network.extensions.toInviteKeyBundle +import com.flipcash.services.internal.network.extensions.toInviteStatus import com.flipcash.services.internal.network.extensions.toKeyEnvelope +import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.internal.network.extensions.toLobbyMember import com.flipcash.services.internal.network.extensions.toRestrictedUser import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.internal.network.services.ChatService +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.GetInviteError +import com.flipcash.services.models.GetKeyEnvelopeError +import com.flipcash.services.models.RevokeInviteError import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.SampleChattersError import com.flipcash.services.models.QueryOptions @@ -22,6 +31,14 @@ import com.flipcash.services.models.chat.SampledChatter import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InvitePreview +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage @@ -35,6 +52,7 @@ import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository +import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID import com.getcode.utils.ErrorUtils @@ -123,10 +141,11 @@ internal class InternalChatRepository( .map { mapper.map(it) } override suspend fun getRoster( - owner: KeyPair, + owner: KeyPair?, chatId: ChatId, queryOptions: QueryOptions, - ): Result = service.getRoster(owner, chatId, queryOptions) + inviteToken: InviteToken?, + ): Result = service.getRoster(owner, chatId, queryOptions, inviteToken) .onFailure { ErrorUtils.handleError(it) } .map { response -> RosterPage( @@ -164,7 +183,8 @@ internal class InternalChatRepository( override suspend fun joinChat( owner: KeyPair, chatId: ChatId, - ): Result = service.joinChat(owner, chatId) + invite: InviteParameters?, + ): Result = service.joinChat(owner, chatId, invite) .onFailure { ErrorUtils.handleError(it) } .map { mapper.map(it) } @@ -275,10 +295,67 @@ internal class InternalChatRepository( override suspend fun getKeyEnvelope(owner: KeyPair, chatId: ChatId): Result = service.getKeyEnvelope(owner, chatId) .onFailure { ErrorUtils.handleError(it) } + .mapCatching { response -> + // keyEnvelope is a oneof member: reading it while invite_key_bundle is set yields a + // default (empty) envelope, so branch on the case before touching it. + when (response.envelopeCase) { + RpcChatService.GetKeyEnvelopeResponse.EnvelopeCase.KEY_ENVELOPE -> StoredKeyEnvelope( + envelope = response.keyEnvelope.toKeyEnvelope(), + wrappedBy = if (response.hasWrappedBy()) response.wrappedBy.toId() else null, + wrappedByPublicKey = if (response.hasWrappedByPublicKey()) response.wrappedByPublicKey.toPublicKey() else null, + ) + RpcChatService.GetKeyEnvelopeResponse.EnvelopeCase.INVITE_KEY_BUNDLE -> + throw GetKeyEnvelopeError.InviteKeyBundleOnly(response.toInviteKeyBundle()) + // An OK with nothing stored, or a case this build does not know: no usable own envelope. + else -> throw GetKeyEnvelopeError.NoEnvelope() + } + } + + override suspend fun createInvite( + owner: KeyPair, + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int, + expiresAt: Instant?, + ): Result = service.createInvite(owner, chatId, token, keyEnvelope, secretEnvelope, maxUses, expiresAt) + .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } + .map { it.toInvite() } + + override suspend fun getInvite(owner: KeyPair?, token: InviteToken): Result = + service.getInvite(owner, token) + .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } .map { response -> - StoredKeyEnvelope( - envelope = response.keyEnvelope.toKeyEnvelope(), - wrappedBy = if (response.hasWrappedBy()) response.wrappedBy.toId() else null, + InvitePreview( + chat = mapper.map(response.chat), + status = response.status.toInviteStatus(), ) } + + override suspend fun getChatInvites( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = service.getChatInvites(owner, chatId, queryOptions) + .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } + .map { response -> + InvitesPage( + invites = response.invitesList.map { it.toInvite() }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + + override suspend fun revokeInvite(owner: KeyPair, chatId: ChatId, inviteId: InviteId): Result = + service.revokeInvite(owner, chatId, inviteId) + .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } + + // Expected outcomes of a stale or bad link; not worth reporting. + private fun Throwable.isExpectedInviteFailure(): Boolean = + this is CreateInviteError.Denied || this is CreateInviteError.NotFound || + this is CreateInviteError.InvalidExpiresAt || this is CreateInviteError.TooManyInvites || + this is GetInviteError.NotFound || this is GetInviteError.Banned || + this is GetChatInvitesError.Denied || this is GetChatInvitesError.NotFound || + this is RevokeInviteError.Denied || this is RevokeInviteError.NotFound } 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 b8feff730b..fa4642b171 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 @@ -1,6 +1,7 @@ package com.flipcash.services.models import com.flipcash.services.models.chat.BlobRejection +import com.flipcash.services.models.chat.InviteKeyBundle import com.getcode.solana.keys.Checksum import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError @@ -468,6 +469,12 @@ sealed class JoinChatError( // The creator or a moderator banned the caller from this chat. Not recoverable by the user, // so deliberately not a NotifiableError. class Banned : JoinChatError("Banned") + // The invite token matches no invite for this chat. Not recoverable by the user (the link is + // bad), so deliberately not a NotifiableError; likewise for the three below. + class InviteNotFound : JoinChatError("Invite not found") + class InviteExhausted : JoinChatError("Invite exhausted") + class InviteExpired : JoinChatError("Invite expired") + class InviteRevoked : JoinChatError("Invite revoked") class Unrecognized : JoinChatError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : JoinChatError(message = cause?.message, cause = cause), NotifiableError } @@ -900,6 +907,10 @@ sealed class GetKeyEnvelopeError( class NotFound : GetKeyEnvelopeError("Not found") // The caller is a member with no envelope stored. class NoEnvelope : GetKeyEnvelopeError("No envelope") + // The caller joined with an invite and has not yet stored an envelope of their own; the server + // holds only the invite's copy of the chat key. Not a usable own envelope, and never to be read + // as one. Carries the bundle so the invite flow can open it once the invite crypto exists. + class InviteKeyBundleOnly(val bundle: InviteKeyBundle) : GetKeyEnvelopeError("Only an invite's key copy is stored") class Unrecognized : GetKeyEnvelopeError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : GetKeyEnvelopeError(message = cause?.message, cause = cause), NotifiableError } @@ -1082,3 +1093,48 @@ sealed class MarkPaymentRequestPaidError( class Unrecognized : MarkPaymentRequestPaidError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : MarkPaymentRequestPaidError(message = cause?.message, cause = cause), NotifiableError } + +sealed class CreateInviteError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : CreateInviteError("Denied") + class NotFound : CreateInviteError("Not found") + class InvalidExpiresAt : CreateInviteError("Invalid expiry") + class TooManyInvites : CreateInviteError("Too many invites") + // The key envelope's signature did not verify against the caller's key. + class InvalidSignature : CreateInviteError("Invalid signature"), NotifiableError + class Unrecognized : CreateInviteError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : CreateInviteError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetInviteError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class NotFound : GetInviteError("Not found") + // The caller is banned from the chat the invite leads to. + class Banned : GetInviteError("Banned") + class Unrecognized : GetInviteError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetInviteError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetChatInvitesError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetChatInvitesError("Denied") + class NotFound : GetChatInvitesError("Not found") + class Unrecognized : GetChatInvitesError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetChatInvitesError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class RevokeInviteError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : RevokeInviteError("Denied") + class NotFound : RevokeInviteError("Not found") + class Unrecognized : RevokeInviteError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : RevokeInviteError(message = cause?.message, cause = cause), NotifiableError +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Invite.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Invite.kt new file mode 100644 index 0000000000..525986b3ae --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Invite.kt @@ -0,0 +1,150 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/* + * Domain models for private-group invite links (`chat.v1` Invite and friends). + * + * Everything here is transport only. The link secret, the token, the invite id and the envelope + * contents are derived, wrapped and verified by client crypto that has to match iOS exactly; none + * of that lives in this layer, so envelopes are carried as opaque bytes. + * TODO(group-invites): invite crypto (HKDF token/wrap key, XChaCha20-Poly1305 envelopes, Ed25519 + * signature) is deliberately not implemented. It is a cross-platform hotspot and waits on its own go. + */ + +/** 16-byte invite id; the server derives it from the [InviteToken]. */ +class InviteId(val bytes: ByteArray) { + override fun equals(other: Any?): Boolean = this === other || (other is InviteId && bytes.contentEquals(other.bytes)) + override fun hashCode(): Int = bytes.contentHashCode() + override fun toString(): String = "InviteId(${bytes.size} bytes)" +} + +/** + * 32-byte token derived from the link's secret. Possession of the token is what lets a viewer + * read a private group through the invite, so it is never printed. + */ +class InviteToken(val bytes: ByteArray) { + override fun equals(other: Any?): Boolean = this === other || (other is InviteToken && bytes.contentEquals(other.bytes)) + override fun hashCode(): Int = bytes.contentHashCode() + override fun toString(): String = "InviteToken(redacted)" +} + +/** + * The chat key encrypted under the invite's wrap key and signed by the inviter. [scheme] is the raw + * `InviteKeyEnvelope.Scheme` number so an unknown scheme survives a round trip. + */ +class InviteKeyEnvelope( + val scheme: Int, + val nonce: ByteArray, + val ciphertext: ByteArray, + val signature: ByteArray, +) { + override fun equals(other: Any?): Boolean = + this === other || (other is InviteKeyEnvelope && + scheme == other.scheme && + nonce.contentEquals(other.nonce) && + ciphertext.contentEquals(other.ciphertext) && + signature.contentEquals(other.signature)) + + override fun hashCode(): Int { + var result = scheme + result = 31 * result + nonce.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + + override fun toString(): String = "InviteKeyEnvelope(scheme=$scheme)" + + companion object { + /** `InviteKeyEnvelope.Scheme.XCHACHA20POLY1305`. */ + const val SCHEME_XCHACHA20POLY1305 = 1 + } +} + +/** + * The link's secret, encrypted by its holder for themself. [scheme] is the raw + * `InviteSecretEnvelope.Scheme` number. + */ +class InviteSecretEnvelope( + val scheme: Int, + val nonce: ByteArray, + val ciphertext: ByteArray, +) { + override fun equals(other: Any?): Boolean = + this === other || (other is InviteSecretEnvelope && + scheme == other.scheme && + nonce.contentEquals(other.nonce) && + ciphertext.contentEquals(other.ciphertext)) + + override fun hashCode(): Int { + var result = scheme + result = 31 * result + nonce.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + return result + } + + override fun toString(): String = "InviteSecretEnvelope(scheme=$scheme)" + + companion object { + /** `InviteSecretEnvelope.Scheme.X25519_XCHACHA20POLY1305`. */ + const val SCHEME_X25519_XCHACHA20POLY1305 = 1 + } +} + +data class Invite( + val id: InviteId, + val chatId: ChatId, + val createdBy: ID, + // 0 means unlimited. Unsigned on the wire, widened so a large value does not go negative. + val maxUses: Long, + val useCount: Long, + val createdAt: Instant, + val expiresAt: Instant?, + val status: Status, + // Present for the inviter's own listing; lets them re-derive the link's secret. + val secretEnvelope: InviteSecretEnvelope?, +) { + enum class Status { + /** Also stands in for a status this build does not know. */ + UNKNOWN, + ACTIVE, + EXHAUSTED, + EXPIRED, + REVOKED, + } +} + +/** What `JoinChat` needs to join a private group with a link: the token and the joiner's secret envelope. */ +class InviteParameters( + val token: InviteToken, + val secretEnvelope: InviteSecretEnvelope, +) + +/** + * The copy of the chat key an invite-joined member holds until they store an envelope of their own + * (`GetKeyEnvelopeResponse.invite_key_bundle`). Opening it needs the link secret and the invite + * crypto, which this layer does not do. + */ +class InviteKeyBundle( + val inviteId: InviteId, + val keyEnvelope: InviteKeyEnvelope, + val secretEnvelope: InviteSecretEnvelope, + // Who signed the copy (the invite's creator), and the key to verify it against, as the server reports them. + val wrappedBy: ID?, + val wrappedByPublicKey: com.getcode.solana.keys.PublicKey?, +) + +/** What a viewer sees of a group through its invite link: the chat's public metadata and the invite's state. */ +data class InvitePreview( + val chat: ChatMetadata, + val status: Invite.Status, +) + +data class InvitesPage( + val invites: List, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt index 42566d362f..b478d47bec 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/KeyEnvelope.kt @@ -1,6 +1,7 @@ package com.flipcash.services.models.chat import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.PublicKey /** * A private group's chat key, wrapped for one user. Opaque to this layer: the bytes are carried @@ -38,9 +39,12 @@ class KeyEnvelope( /** * The caller's stored [envelope] and [wrappedBy], the user whose key wrapped it. Null - * [wrappedBy] when the server did not say. + * [wrappedBy] when the server did not say. [wrappedByPublicKey] is the key the server reports + * for [wrappedBy], to open the envelope against when the client has learned none elsewhere; a + * hint only, null when the server did not send it. */ data class StoredKeyEnvelope( val envelope: KeyEnvelope, val wrappedBy: ID?, + val wrappedByPublicKey: PublicKey? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 02cdcdc524..7fe5f67f99 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -90,8 +90,12 @@ sealed interface MessageContent { /** The `messaging.v1.SystemContent.type` variants this client recognises. */ sealed interface SystemEvent { - /** [userId] joined the chat; [admittedBy] let them in, or null when they joined on their own. */ - data class UserJoined(val userId: ID, val admittedBy: ID?) : SystemEvent + /** + * [userId] joined the chat; [admittedBy] let them in, or null when they joined on their own. + * [invitedBy] is the user whose invite link they used, null for any other kind of join; it + * and [admittedBy] are never both set. + */ + data class UserJoined(val userId: ID, val admittedBy: ID?, val invitedBy: ID? = null) : SystemEvent } data class Deleted( val deletedTs: Instant, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt index f479c5ef61..0ab9f7bdb0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt @@ -20,6 +20,9 @@ data class ViewerState( data class Permissions( // Whether the viewer may call EditChat on this chat. val canEdit: Boolean = false, + // Whether the viewer may create, list and revoke invite links. True today only for a + // private group's creator while a member. + val canInvite: Boolean = false, // Which moderation actions the viewer may take on other users. val moderation: ModerationPermissions = ModerationPermissions(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index 4acba2852d..5623e0d127 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -10,6 +10,14 @@ import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InvitePreview +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage @@ -24,6 +32,7 @@ import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID +import kotlin.time.Instant interface ChatRepository { /** @@ -92,11 +101,16 @@ interface ChatRepository { * * See [RosterPage] for how to merge a page into a locally held roster — a page can lag * the roster's true state for a large group, unlike the exact list [getChat] returns. + * + * [owner] may be null when [inviteToken] is set: the token stands in for auth so a viewer + * holding an invite link can read the roster before joining. Send the token on every page. + * With neither, the call fails. */ suspend fun getRoster( - owner: KeyPair, + owner: KeyPair?, chatId: ChatId, queryOptions: QueryOptions = QueryOptions(), + inviteToken: InviteToken? = null, ): Result /** @@ -121,10 +135,14 @@ interface ChatRepository { parameters: EditChatParameters, ): Result - /** Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. */ + /** + * Adds the caller to [chatId]'s roster, returning the chat as the caller now sees it. Pass + * [invite] to join a private group with a link; failures then include the `JoinChatError.Invite*` cases. + */ suspend fun joinChat( owner: KeyPair, chatId: ChatId, + invite: InviteParameters? = null, ): Result /** @@ -212,6 +230,46 @@ interface ChatRepository { */ suspend fun setKeyEnvelope(owner: KeyPair, chatId: ChatId, keyEnvelope: KeyEnvelope): Result - /** The caller's own key envelope for [chatId], or [GetKeyEnvelopeError.NoEnvelope]. */ + /** + * The caller's own key envelope for [chatId], or [GetKeyEnvelopeError.NoEnvelope]. A member who + * joined with an invite and has not stored an envelope yet fails with + * [GetKeyEnvelopeError.InviteKeyBundleOnly], which carries the invite's copy; that copy is never + * returned as an own envelope. + */ suspend fun getKeyEnvelope(owner: KeyPair, chatId: ChatId): Result + + /** + * Creates an invite link for private group [chatId]. Requires `ViewerState.Permissions.canInvite`. + * Idempotent on [token]: a retry returns the invite the first attempt created. [maxUses] 0 means + * unlimited; null [expiresAt] never expires. The envelopes come from invite crypto, which is not + * implemented in this layer. + */ + suspend fun createInvite( + owner: KeyPair, + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int = 0, + expiresAt: Instant? = null, + ): Result + + /** + * What [token]'s link leads to: the group's public metadata and the invite's status. [owner] is + * optional; an anonymous viewer can look an invite up. + */ + suspend fun getInvite(owner: KeyPair?, token: InviteToken): Result + + /** One page of [chatId]'s invites. Creator only (`canInvite`). */ + suspend fun getChatInvites( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result + + /** + * Revokes [inviteId] on [chatId]. Banning a user does not stop a holder of the link, so revoke + * the invite as well. + */ + suspend fun revokeInvite(owner: KeyPair, chatId: ChatId, inviteId: InviteId): Result } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index 8385188776..562847ca6f 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -9,6 +9,14 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InvitePreview +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage @@ -567,9 +575,10 @@ private class FakeChatRepository : ChatRepository { ): Result> = Result.success(emptyList()) override suspend fun getRoster( - owner: Ed25519.KeyPair, + owner: Ed25519.KeyPair?, chatId: ChatId, queryOptions: QueryOptions, + inviteToken: InviteToken?, ): Result { lastChatId = chatId lastQueryOptions = queryOptions @@ -586,7 +595,7 @@ private class FakeChatRepository : ChatRepository { return editChatResult } - override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + override suspend fun joinChat(owner: Ed25519.KeyPair, chatId: ChatId, invite: InviteParameters?): Result { lastChatId = chatId return joinChatResult } @@ -692,6 +701,28 @@ private class FakeChatRepository : ChatRepository { lastChatId = chatId return getKeyEnvelopeResult } + + override suspend fun createInvite( + owner: Ed25519.KeyPair, + chatId: ChatId, + token: InviteToken, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: InviteSecretEnvelope, + maxUses: Int, + expiresAt: Instant?, + ): Result = Result.failure(RuntimeException("not configured")) + + override suspend fun getInvite(owner: Ed25519.KeyPair?, token: InviteToken): Result = + Result.failure(RuntimeException("not configured")) + + override suspend fun getChatInvites( + owner: Ed25519.KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = Result.failure(RuntimeException("not configured")) + + override suspend fun revokeInvite(owner: Ed25519.KeyPair, chatId: ChatId, inviteId: InviteId): Result = + Result.failure(RuntimeException("not configured")) } // endregion From f2c07c47c27af26629156fee31b3fea3eb48d789 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 15:58:27 -0400 Subject: [PATCH 2/2] chore(deps): pin flipcash2-client-protocol 0.22.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7204229fd9..27ae2741f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.9.0" -flipcash2-client-protocol = "0.21.0" +flipcash2-client-protocol = "0.22.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,