diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db86a0cdc..a538db0f7 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1846cdd3f2b4b3cb3b2bf4598b1112117904f5b06345e8d788368cf0a0bf83ce", + "originHash" : "769a9973cc2072509500c7e8d45c1a9dafbba00e8cb7a9451e70f57c98481658", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "a73ec1dff18a77a698af68add72f1856b6005b2a", - "version" : "0.21.0" + "revision" : "ad5e650ce239a94f555877a8d3ef747db9e159fc", + "version" : "0.22.0" } }, { diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 80e344875..e0a838bb8 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -569,6 +569,10 @@ extension Analytics { case .notFound: "NotFound" case .rulesNotSatisfied: "RulesNotSatisfied" case .banned: "Banned" + case .inviteNotFound: "InviteNotFound" + case .inviteExhausted: "InviteExhausted" + case .inviteExpired: "InviteExpired" + case .inviteRevoked: "InviteRevoked" case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" } case let error as ErrorLeaveChat: diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 6378a0f70..87bab4f22 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.9.0" - case .flipcash2: return "0.21.0" + case .flipcash2: return "0.22.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 64cda98bb..30b4072ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -94,7 +94,9 @@ public final class ChatNotificationClient: Sendable { continuation.resume(with: $0) } } - return (result.envelope, result.wrappedBy) + // An invite-joined member has only the invite's copy until they store their own. + guard let own = result.ownEnvelope else { throw ErrorGetKeyEnvelope.noEnvelope } + return own }, resolveKey: resolveKey ) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 27e1847fd..31745cfdf 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -144,6 +144,14 @@ extension FlipClient { } } + /// Joins a private group through an invite link. The failures include `.inviteNotFound`, + /// `.inviteExhausted`, `.inviteExpired` and `.inviteRevoked`. + public func joinChat(owner: KeyPair, conversationID: ConversationID, invite: InviteJoinParameters) async throws -> Conversation { + try await withCheckedThrowingContinuation { c in + chatService.joinChat(owner: owner, conversationID: conversationID, invite: invite) { c.resume(with: $0) } + } + } + /// Leaves a group chat. The caller's own `rosterChanged` event (naming itself) is what actually /// drops the chat from the local feed and database — this call just tells the server to emit it. public func leaveChat(owner: KeyPair, conversationID: ConversationID) async throws { @@ -169,14 +177,16 @@ extension FlipClient { /// consuming its `subscribeConversationStream` events — as with `getDmChatFeed`/ /// `getGroupChatFeed` — and must merge the result against them by ``ConversationMember/version`` /// rather than treating it as an authoritative snapshot; see ``RosterPage``. - public func getRoster(owner: KeyPair, conversationID: ConversationID) async throws -> RosterPage { + /// + /// `owner` may be `nil` when `inviteToken` is given: the token stands in for auth. + public func getRoster(owner: KeyPair?, conversationID: ConversationID, inviteToken: InviteToken? = nil) async throws -> RosterPage { var members: [ConversationMember] = [] var rosterSummary = ConversationRosterSummary(memberCount: 0, version: 0) var pagingToken: Data? while true { let page = try await withCheckedThrowingContinuation { c in - chatService.getRoster(owner: owner, conversationID: conversationID, pagingToken: pagingToken) { c.resume(with: $0) } + chatService.getRoster(owner: owner, conversationID: conversationID, inviteToken: inviteToken, pagingToken: pagingToken) { c.resume(with: $0) } } members.append(contentsOf: page.members) rosterSummary = page.rosterSummary @@ -462,6 +472,52 @@ extension FlipClient { let result = try await withCheckedThrowingContinuation { c in chatService.getKeyEnvelope(owner: owner, conversationID: conversationID) { c.resume(with: $0) } } - return (result.envelope, result.wrappedBy) + // An invite-joined member has only the invite's copy until they store their own envelope. + guard let own = result.ownEnvelope else { throw ErrorGetKeyEnvelope.noEnvelope } + return own + } + + // MARK: - Invites - + + /// Creates an invite to a private group the caller may invite to (``ConversationViewerState/canInvite``). + /// Idempotent on `token`; `maxUses` of `0` means unlimited. The caller supplies the envelopes — + /// deriving and sealing them is not implemented in this client yet. + public func createInvite(owner: KeyPair, conversationID: ConversationID, token: InviteToken, keyEnvelope: InviteKeyEnvelope, secretEnvelope: InviteSecretEnvelope, maxUses: UInt32 = 0, expiresAt: Date? = nil) async throws -> Invite { + try await withCheckedThrowingContinuation { c in + chatService.createInvite(owner: owner, conversationID: conversationID, token: token, keyEnvelope: keyEnvelope, secretEnvelope: secretEnvelope, maxUses: maxUses, expiresAt: expiresAt) { c.resume(with: $0) } + } + } + + /// Reads the group an invite is for and the invite's status. `owner` is optional: the read is + /// allowed anonymously. + public func getInvite(owner: KeyPair?, token: InviteToken) async throws -> (conversation: Conversation, status: Invite.Status) { + let preview = try await withCheckedThrowingContinuation { c in + chatService.getInvite(owner: owner, token: token) { c.resume(with: $0) } + } + return (preview.conversation, preview.status) + } + + /// A group's invites, paged to exhaustion. + public func getChatInvites(owner: KeyPair, conversationID: ConversationID) async throws -> [Invite] { + var invites: [Invite] = [] + var pagingToken: Data? + + while true { + let page = try await withCheckedThrowingContinuation { c in + chatService.getChatInvites(owner: owner, conversationID: conversationID, pagingToken: pagingToken) { c.resume(with: $0) } + } + invites.append(contentsOf: page.invites) + if !page.hasMore { break } + pagingToken = page.pagingToken + } + + return invites + } + + /// Revokes an invite. A ban does not stop a link holder, so revoke the invite too when banning. + public func revokeInvite(owner: KeyPair, conversationID: ConversationID, inviteID: InviteID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.revokeInvite(owner: owner, conversationID: conversationID, inviteID: inviteID) { c.resume(with: $0) } + } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 47e699195..350b2a0ad 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -202,9 +202,17 @@ final class ChatService: Sendable { } } - func joinChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + /// Joins `conversationID`. Pass `invite` to enter a private group through an invite link: the + /// token and the joiner's secret envelope travel together, and the server rejects either alone. + func joinChat(owner: KeyPair, conversationID: ConversationID, invite: InviteJoinParameters? = nil, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_JoinChatRequest.with { $0.chatID = conversationID.proto + if let invite { + $0.invite = .with { + $0.inviteToken = invite.token.proto + $0.secretEnvelope = invite.secretEnvelope.proto + } + } $0.auth = owner.authFor(message: $0) } @@ -467,7 +475,10 @@ final class ChatService: Sendable { /// staleness contract) — callers must merge each page against what the event stream has already /// told them, by ``ConversationMember/version``, greater winning, rather than trusting a page to /// be a complete, current snapshot. - func getRoster(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + /// + /// `owner` is optional because an `inviteToken` can stand in for auth: a link holder may read the + /// roster of the group the invite is for before joining. + func getRoster(owner: KeyPair?, conversationID: ConversationID, inviteToken: InviteToken? = nil, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_GetRosterRequest.with { $0.chatID = conversationID.proto $0.queryOptions = .with { @@ -476,7 +487,12 @@ final class ChatService: Sendable { $0.pagingToken = .with { $0.value = pagingToken } } } - $0.auth = owner.authFor(message: $0) + if let inviteToken { + $0.inviteToken = inviteToken.proto + } + if let owner { + $0.auth = owner.authFor(message: $0) + } } Task { @@ -876,10 +892,158 @@ final class ChatService: Sendable { } } - struct KeyEnvelopeResult: Sendable { - let envelope: ConversationKeyEnvelope - /// The member who wrapped the chat key for the signed-in user. - let wrappedBy: UserID? + /// The key material the server holds for the signed-in user in a chat. + enum KeyEnvelopeResult: Sendable { + /// The user's own envelope, wrapped by themself or by another member. + case own(envelope: ConversationKeyEnvelope, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?) + /// The user joined by invite and has not yet stored an envelope of their own: the server + /// returns the invite's copy of the chat key instead. It is not a usable own envelope. + case invite(InviteKeyBundle, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?) + + /// The user's own envelope and who wrapped it, or `nil` while only the invite copy exists. + var ownEnvelope: (envelope: ConversationKeyEnvelope, wrappedBy: UserID?)? { + switch self { + case .own(let envelope, let wrappedBy, _): (envelope, wrappedBy) + case .invite: nil + } + } + } + + // MARK: - Invites - + + /// Creates an invite to a private group. Idempotent on `token`. `maxUses` of `0` means unlimited. + func createInvite(owner: KeyPair, conversationID: ConversationID, token: InviteToken, keyEnvelope: InviteKeyEnvelope, secretEnvelope: InviteSecretEnvelope, maxUses: UInt32 = 0, expiresAt: Date? = nil, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_CreateInviteRequest.with { + $0.chatID = conversationID.proto + $0.inviteToken = token.proto + $0.keyEnvelope = keyEnvelope.proto + $0.secretEnvelope = secretEnvelope.proto + $0.maxUses = maxUses + if let expiresAt { + $0.expiresAt = .init(date: expiresAt) + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.createInvite(request, options: .unaryDefault) + let error = ErrorCreateInvite(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasInvite else { + logger.error("Failed to create invite", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(Invite(response.invite))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + struct InvitePreview: Sendable { + let conversation: Conversation + let status: Invite.Status + } + + /// Reads the group an invite is for and the invite's status. Anonymous reads are allowed, so + /// `owner` is optional; a caller banned from the group gets `.banned`. + func getInvite(owner: KeyPair?, token: InviteToken, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetInviteRequest.with { + $0.inviteToken = token.proto + if let owner { + $0.auth = owner.authFor(message: $0) + } + } + + Task { + do { + let response = try await service.getInvite(request, options: .unaryDefault) + let error = ErrorGetInvite(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasChat else { + logger.error("Failed to fetch invite", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + let preview = InvitePreview(conversation: Conversation(response.chat), status: Invite.Status(response.status)) + await MainActor.run { completion(.success(preview)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + struct InvitesPage: Sendable { + let invites: [Invite] + let pagingToken: Data + let hasMore: Bool + } + + /// Pages a group's invites. Leave `pagingToken` `nil` on the first call and pass back the previous + /// page's token after that. `pageSize` is capped at 100 server-side. + func getChatInvites(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetChatInvitesRequest.with { + $0.chatID = conversationID.proto + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getChatInvites(request, options: .unaryDefault) + let error = ErrorGetChatInvites(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch chat invites", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + let page = InvitesPage( + invites: response.invites.map(Invite.init), + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Revokes an invite. A ban does not stop a link holder, so revoke the invite too when banning. + func revokeInvite(owner: KeyPair, conversationID: ConversationID, inviteID: InviteID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_RevokeInviteRequest.with { + $0.chatID = conversationID.proto + $0.inviteID = inviteID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.revokeInvite(request, options: .unaryDefault) + let error = ErrorRevokeInvite(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to revoke invite", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } } /// Fetches the chat key `conversationID` wrapped for the signed-in user, along with who @@ -894,15 +1058,24 @@ final class ChatService: Sendable { do { let response = try await service.getKeyEnvelope(request, options: .unaryDefault) let error = ErrorGetKeyEnvelope(rawValue: response.result.rawValue) ?? .unknown - guard error == .ok, response.hasKeyEnvelope else { + guard error == .ok else { logger.error("Failed to fetch key envelope") - await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + await MainActor.run { completion(.failure(error)) } + return + } + let wrappedBy = response.hasWrappedBy ? try? UUID(data: response.wrappedBy.value) : nil + let wrappedByPublicKey = response.hasWrappedByPublicKey ? try? PublicKey(response.wrappedByPublicKey.value) : nil + let result: KeyEnvelopeResult + switch response.envelope { + case .keyEnvelope(let envelope): + result = .own(envelope: ConversationKeyEnvelope(envelope), wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey) + case .inviteKeyBundle(let bundle): + result = .invite(InviteKeyBundle(bundle), wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey) + case nil: + logger.error("Key envelope response carried no envelope") + await MainActor.run { completion(.failure(.unknown)) } return } - let result = KeyEnvelopeResult( - envelope: ConversationKeyEnvelope(response.keyEnvelope), - wrappedBy: response.hasWrappedBy ? try? UUID(data: response.wrappedBy.value) : nil - ) await MainActor.run { completion(.success(result)) } } catch let error as RPCError { await MainActor.run { completion(.failure(.from(transportError: error))) } @@ -969,6 +1142,10 @@ public enum ErrorJoinChat: Int, Error { case notFound case rulesNotSatisfied case banned + case inviteNotFound + case inviteExhausted + case inviteExpired + case inviteRevoked case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -1131,6 +1308,49 @@ public enum ErrorDenyLobbyMember: Int, Error { case rejected = -4 } +public enum ErrorCreateInvite: Int, Error { + case ok + case denied + case notFound + case invalidExpiresAt + case tooManyInvites + case invalidSignature + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetInvite: Int, Error { + case ok + case notFound + case banned + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetChatInvites: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorRevokeInvite: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + public enum ErrorSetKeyEnvelope: Int, Error { case ok case denied @@ -1233,6 +1453,7 @@ extension ErrorJoinChat: ServerError, TransportClassifiableError { case .ok, .transportFailure: .suppressed case .cancelled: .info case .denied, .notFound, .rulesNotSatisfied, .banned: .info + case .inviteNotFound, .inviteExhausted, .inviteExpired, .inviteRevoked: .info case .unknown, .rejected: .error } } @@ -1326,6 +1547,50 @@ extension ErrorDenyLobbyMember: ServerError, TransportClassifiableError { } } +extension ErrorCreateInvite: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .invalidExpiresAt, .tooManyInvites: .info + case .invalidSignature, .unknown, .rejected: .error + } + } +} + +extension ErrorGetInvite: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .notFound, .banned: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetChatInvites: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorRevokeInvite: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + extension ErrorSetKeyEnvelope: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift index 89cac6ca5..dd82317c6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift @@ -38,6 +38,10 @@ public enum SystemNoticeStrings { guard let admitterName = nameOf(admitter) else { return fallbackText } if isViewerJoiner { return "\(admitterName) added you" } return nameOf(joiner).map { "\(admitterName) added \($0)" } ?? fallbackText + case .userJoinedViaInvite(let joiner, _): + // Worded as a plain join until the invite notice copy is settled with Android. + if joiner == selfID { return "You joined the group" } + return nameOf(joiner).map { "\($0) joined the group" } ?? fallbackText } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationInvite.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationInvite.swift new file mode 100644 index 000000000..03399ec26 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationInvite.swift @@ -0,0 +1,203 @@ +// +// ConversationInvite.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +// The invite wire shapes only. Deriving the token and id, wrapping and unwrapping the envelopes, and +// signing or verifying them is a cross-platform shared-logic hotspot that is not implemented yet, so +// the envelopes stay opaque bytes, as ``ConversationKeyEnvelope`` does. + +/// A group invite's 16-byte identifier, derived from its ``InviteToken``. +public struct InviteID: Hashable, Sendable { + public let data: Data + + public init(data: Data) { + self.data = data + } + + init(_ proto: Flipcash_Chat_V1_InviteId) { + self.data = proto.value + } + + var proto: Flipcash_Chat_V1_InviteId { + .with { $0.value = data } + } +} + +/// The 32-byte bearer token a link holder presents to read or join through an invite. +public struct InviteToken: Hashable, Sendable { + public let data: Data + + public init(data: Data) { + self.data = data + } + + init(_ proto: Flipcash_Chat_V1_InviteToken) { + self.data = proto.value + } + + var proto: Flipcash_Chat_V1_InviteToken { + .with { $0.value = data } + } +} + +/// The chat key wrapped under an invite's link secret and signed by the inviter. +public struct InviteKeyEnvelope: Hashable, Sendable { + /// The proto `InviteKeyEnvelope.Scheme` raw value. + public let scheme: Int + public let nonce: Data + public let ciphertext: Data + /// The inviter's Ed25519 signature over the envelope. + public let signature: Data + + public init(scheme: Int, nonce: Data, ciphertext: Data, signature: Data) { + self.scheme = scheme + self.nonce = nonce + self.ciphertext = ciphertext + self.signature = signature + } + + init(_ proto: Flipcash_Chat_V1_InviteKeyEnvelope) { + self.init(scheme: proto.scheme.rawValue, nonce: proto.nonce, ciphertext: proto.ciphertext, signature: proto.signature) + } + + var proto: Flipcash_Chat_V1_InviteKeyEnvelope { + .with { + $0.scheme = Flipcash_Chat_V1_InviteKeyEnvelope.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + $0.signature = signature + } + } +} + +/// The invite's link secret wrapped to its owner's key, so the inviter can show the link again. +public struct InviteSecretEnvelope: Hashable, Sendable { + /// The proto `InviteSecretEnvelope.Scheme` raw value. + public let scheme: Int + public let nonce: Data + public let ciphertext: Data + + public init(scheme: Int, nonce: Data, ciphertext: Data) { + self.scheme = scheme + self.nonce = nonce + self.ciphertext = ciphertext + } + + init(_ proto: Flipcash_Chat_V1_InviteSecretEnvelope) { + self.init(scheme: proto.scheme.rawValue, nonce: proto.nonce, ciphertext: proto.ciphertext) + } + + var proto: Flipcash_Chat_V1_InviteSecretEnvelope { + .with { + $0.scheme = Flipcash_Chat_V1_InviteSecretEnvelope.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + } + } +} + +/// What a joiner presents with `joinChat` to enter a private group through an invite. +public struct InviteJoinParameters: Hashable, Sendable { + public let token: InviteToken + public let secretEnvelope: InviteSecretEnvelope + + public init(token: InviteToken, secretEnvelope: InviteSecretEnvelope) { + self.token = token + self.secretEnvelope = secretEnvelope + } +} + +/// A group invite as the server reports it. +public struct Invite: Hashable, Sendable { + + /// Whether the invite still admits users. Only ``active`` does. + public enum Status: Hashable, Sendable { + case unknown + case active + case exhausted + case expired + case revoked + } + + public let id: InviteID + public let conversationID: ConversationID + public let createdBy: UserID? + /// `0` means unlimited. + public let maxUses: UInt32 + public let useCount: UInt32 + public let createdAt: Date? + public let expiresAt: Date? + public let status: Status + /// The link secret wrapped to the creator. `nil` when the server sent none. + public let secretEnvelope: InviteSecretEnvelope? + + public init(id: InviteID, conversationID: ConversationID, createdBy: UserID?, maxUses: UInt32, useCount: UInt32, createdAt: Date?, expiresAt: Date?, status: Status, secretEnvelope: InviteSecretEnvelope?) { + self.id = id + self.conversationID = conversationID + self.createdBy = createdBy + self.maxUses = maxUses + self.useCount = useCount + self.createdAt = createdAt + self.expiresAt = expiresAt + self.status = status + self.secretEnvelope = secretEnvelope + } +} + +extension Invite.Status { + init(_ proto: Flipcash_Chat_V1_Invite.Status) { + switch proto { + case .active: self = .active + case .exhausted: self = .exhausted + case .expired: self = .expired + case .revoked: self = .revoked + case .unknown, .UNRECOGNIZED: self = .unknown + } + } +} + +extension Invite { + init(_ proto: Flipcash_Chat_V1_Invite) { + self.init( + id: InviteID(proto.inviteID), + conversationID: ConversationID(proto.chatID), + createdBy: proto.hasCreatedBy ? try? UUID(data: proto.createdBy.value) : nil, + maxUses: proto.maxUses, + useCount: proto.useCount, + createdAt: proto.hasCreatedAt ? proto.createdAt.date : nil, + expiresAt: proto.hasExpiresAt ? proto.expiresAt.date : nil, + status: Status(proto.status), + secretEnvelope: proto.hasSecretEnvelope ? InviteSecretEnvelope(proto.secretEnvelope) : nil + ) + } +} + +/// An invite-joined member's stored copy of the chat key, returned in place of their own envelope +/// until they replace it with one of their own. +public struct InviteKeyBundle: Hashable, Sendable { + public let inviteID: InviteID + public let keyEnvelope: InviteKeyEnvelope + public let secretEnvelope: InviteSecretEnvelope + + public init(inviteID: InviteID, keyEnvelope: InviteKeyEnvelope, secretEnvelope: InviteSecretEnvelope) { + self.inviteID = inviteID + self.keyEnvelope = keyEnvelope + self.secretEnvelope = secretEnvelope + } +} + +extension InviteKeyBundle { + init(_ proto: Flipcash_Chat_V1_GetKeyEnvelopeResponse.InviteKeyBundle) { + self.init( + inviteID: InviteID(proto.inviteID), + keyEnvelope: InviteKeyEnvelope(proto.keyEnvelope), + secretEnvelope: InviteSecretEnvelope(proto.secretEnvelope) + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 3e3114610..1d5cae617 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -84,6 +84,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { public enum SystemEvent: Hashable, Sendable { /// `userID` joined the group, directly or, when `admittedBy` is set, admitted by that user. case userJoined(userID: UserID, admittedBy: UserID?) + /// `userID` joined through an invite link created by `invitedBy`, when the server names them. + case userJoinedViaInvite(userID: UserID, invitedBy: UserID?) /// The users this event names, in reading order, without duplicates and without `selfID`. public func namedUserIDs(excluding selfID: UserID?) -> [UserID] { @@ -94,6 +96,12 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { ids.append(id) } return ids + case .userJoinedViaInvite(let userID, let invitedBy): + var ids: [UserID] = [] + for id in [invitedBy, userID].compactMap({ $0 }) where id != selfID && !ids.contains(id) { + ids.append(id) + } + return ids } } } @@ -573,6 +581,9 @@ extension ConversationMessage.Content { case .admitted(let admitted): let admitter = admitted.hasAdmittedBy ? try? UUID(data: admitted.admittedBy.value) : nil return .userJoined(userID: userID, admittedBy: admitter) + case .invited(let invited): + let inviter = invited.hasInvitedBy ? try? UUID(data: invited.invitedBy.value) : nil + return .userJoinedViaInvite(userID: userID, invitedBy: inviter) case .direct, .none: return .userJoined(userID: userID, admittedBy: nil) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 1a2f1b9fb..2320345c9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -72,13 +72,17 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { /// Whether the viewer may ban other members. Server-computed; never derive it client-side. public let canBan: Bool - public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0, silence: ConversationSilenceState? = nil, canSilence: Bool = false, canBan: Bool = false) { + /// Whether the viewer may create invites to this chat. Server-computed; never derive it client-side. + public let canInvite: Bool + + public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0, silence: ConversationSilenceState? = nil, canSilence: Bool = false, canBan: Bool = false, canInvite: Bool = false) { self.mute = mute self.canEdit = canEdit self.version = version self.silence = silence self.canSilence = canSilence self.canBan = canBan + self.canInvite = canInvite } } @@ -90,7 +94,8 @@ extension ConversationViewerState { version: proto.version, silence: (proto.hasRestrictions && proto.restrictions.hasSilence) ? ConversationSilenceState(proto.restrictions.silence) : nil, canSilence: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canSilence : false, - canBan: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canBan : false + canBan: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canBan : false, + canInvite: proto.hasPermissions ? proto.permissions.canInvite : false ) } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 22ccebe10..86026107b 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -614,6 +614,8 @@ nonisolated extension Database { var linkPreviewJson: Data? var systemUserId: UUID? var systemAdmittedBy: UUID? + var systemInvitedBy: UUID? + var systemViaInvite: Bool? if let sealed = message.sealed { encryptedScheme = sealed.scheme encryptedNonce = sealed.nonce @@ -661,6 +663,10 @@ nonisolated extension Database { case .userJoined(let userID, let admittedBy): systemUserId = userID systemAdmittedBy = admittedBy + case .userJoinedViaInvite(let userID, let invitedBy): + systemUserId = userID + systemInvitedBy = invitedBy + systemViaInvite = true case nil: break } @@ -707,6 +713,8 @@ nonisolated extension Database { m.linkPreviewJson <- linkPreviewJson, m.systemUserId <- systemUserId, m.systemAdmittedBy <- systemAdmittedBy, + m.systemInvitedBy <- systemInvitedBy, + m.systemViaInvite <- systemViaInvite, m.cashPaymentsJson <- Self.encodeCashPayments(message.cashPayments), m.cashReceiverId <- message.cashReceiverID ) @@ -939,8 +947,10 @@ nonisolated extension Database { redacted = media.redacted case 6: guard let text = row[m.text] else { return nil } - let event = row[m.systemUserId].map { - ConversationMessage.SystemEvent.userJoined(userID: $0, admittedBy: row[m.systemAdmittedBy]) + let event = row[m.systemUserId].map { userID in + row[m.systemViaInvite] == true + ? ConversationMessage.SystemEvent.userJoinedViaInvite(userID: userID, invitedBy: row[m.systemInvitedBy]) + : ConversationMessage.SystemEvent.userJoined(userID: userID, admittedBy: row[m.systemAdmittedBy]) } content = .system(fallbackText: text, event: event) default: diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 8f4c34ac4..74ad6307c 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 59 + public static let schemaVersion = 60 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 18ad12fad..74c6aae84 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -404,6 +404,11 @@ nonisolated public struct ConversationMessageTable: Sendable { // `systemUserId` is set. public let systemUserId = Expression ("systemUserId") public let systemAdmittedBy = Expression ("systemAdmittedBy") + // A `kind` 6 row's `SystemEvent.userJoinedViaInvite`: the user who created the invite, when the + // server names them. Set only together with `systemUserId` and never with `systemAdmittedBy`; + // `systemViaInvite` is what marks the row as an invite join, since `systemInvitedBy` may be nil. + public let systemInvitedBy = Expression ("systemInvitedBy") + public let systemViaInvite = Expression ("systemViaInvite") // JSON-encoded `[CashPayment]` of a multi-receiver cash row; nil for every other row. public let cashPaymentsJson = Expression ("cashPaymentsJson") // The member a single-receiver group cash row paid; nil for DM cash and every other row. @@ -705,6 +710,8 @@ nonisolated extension Database { t.column(conversationMessageTable.linkPreviewJson) t.column(conversationMessageTable.systemUserId) t.column(conversationMessageTable.systemAdmittedBy) + t.column(conversationMessageTable.systemInvitedBy) + t.column(conversationMessageTable.systemViaInvite) t.column(conversationMessageTable.cashPaymentsJson) t.column(conversationMessageTable.cashReceiverId) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 8c63eaf75..79fe23254 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -75,6 +75,31 @@ struct ConversationModelMappingTests { #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: admitter))) } + @Test("An invited join maps to a userJoinedViaInvite event carrying the inviter") + func invitedJoin() throws { + let joiner = UUID() + let inviter = UUID() + let message = try systemMessage { + $0.userJoined = .with { + $0.userID = .with { $0.value = joiner.data } + $0.invited = .with { $0.invitedBy = .with { $0.value = inviter.data } } + } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoinedViaInvite(userID: joiner, invitedBy: inviter))) + } + + @Test("An invited join without invited_by still reads as an invite join") + func invitedWithoutInviter() throws { + let joiner = UUID() + let message = try systemMessage { + $0.userJoined = .with { + $0.userID = .with { $0.value = joiner.data } + $0.invited = .init() + } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoinedViaInvite(userID: joiner, invitedBy: nil))) + } + @Test("A join with no method is a direct join") func joinWithoutMethod() throws { let joiner = UUID() @@ -641,6 +666,34 @@ struct ConversationModelMappingTests { #expect(ConversationViewerState(proto).canEdit) } + @Test("ViewerState maps permissions.canInvite and defaults it to false") + func viewerStateMapsCanInvite() { + let granted = Flipcash_Chat_V1_ViewerState.with { $0.permissions = .with { $0.canInvite = true } } + #expect(ConversationViewerState(granted).canInvite) + #expect(ConversationViewerState(Flipcash_Chat_V1_ViewerState()).canInvite == false) + } + + @Test("Invite maps its fields and status") + func inviteMapping() { + let creator = UUID() + let proto = Flipcash_Chat_V1_Invite.with { + $0.inviteID = .with { $0.value = Data(repeating: 1, count: 16) } + $0.chatID = .with { $0.value = Data(repeating: 2, count: 32) } + $0.createdBy = .with { $0.value = creator.data } + $0.maxUses = 5 + $0.useCount = 2 + $0.status = .exhausted + $0.secretEnvelope = .with { $0.scheme = .x25519Xchacha20Poly1305; $0.nonce = Data([1]); $0.ciphertext = Data([2]) } + } + let invite = Invite(proto) + #expect(invite.id.data == Data(repeating: 1, count: 16)) + #expect(invite.createdBy == creator) + #expect(invite.maxUses == 5 && invite.useCount == 2) + #expect(invite.status == .exhausted) + #expect(invite.expiresAt == nil) + #expect(invite.secretEnvelope == InviteSecretEnvelope(scheme: 1, nonce: Data([1]), ciphertext: Data([2]))) + } + @Test("ViewerState without permissions defaults canEdit to false (never derivable client-side)") func viewerStateWithoutPermissionsDefaultsCanEditFalse() { let proto = Flipcash_Chat_V1_ViewerState.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift index 0b800e331..db9ecf67c 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift @@ -65,6 +65,13 @@ struct SystemNoticeStringsTests { #expect(text(.userJoined(userID: bob, admittedBy: me)) == "fallback") } + @Test("An invite join reads as a plain join") + func invitedJoin() { + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: [ada: "Ada", bob: "Bob"]) == "Ada joined the group") + #expect(text(.userJoinedViaInvite(userID: me, invitedBy: bob)) == "You joined the group") + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: nil)) == "fallback") + } + @Test("A notice with no event shows its fallback text") func noEvent() { #expect(text(nil) == "fallback") diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index ee089183f..cda8599c4 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -98,6 +98,10 @@ struct TransportClassificationTests { @Test func errorGetPolls() { assertClassifies(ErrorGetPolls.self) } @Test func errorVote() { assertClassifies(ErrorVote.self) } @Test func errorJoinChat() { assertClassifies(ErrorJoinChat.self) } + @Test func errorCreateInvite() { assertClassifies(ErrorCreateInvite.self) } + @Test func errorGetInvite() { assertClassifies(ErrorGetInvite.self) } + @Test func errorGetChatInvites() { assertClassifies(ErrorGetChatInvites.self) } + @Test func errorRevokeInvite() { assertClassifies(ErrorRevokeInvite.self) } @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) } @Test func errorUnmuteChat() { assertClassifies(ErrorUnmuteChat.self) } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 5f8ac5799..960a9e54b 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -574,7 +574,9 @@ struct DatabaseConversationsTests { try database.upsertConversationMessages( [system(1, .userJoined(userID: joiner, admittedBy: nil)), system(2, .userJoined(userID: joiner, admittedBy: admitter)), - system(3, nil)], + system(3, nil), + system(4, .userJoinedViaInvite(userID: joiner, invitedBy: admitter)), + system(5, .userJoinedViaInvite(userID: joiner, invitedBy: nil))], conversationID: id ) let loaded = try database.getConversationMessages(conversationID: id) @@ -582,6 +584,8 @@ struct DatabaseConversationsTests { .system(fallbackText: "notice 1", event: .userJoined(userID: joiner, admittedBy: nil)), .system(fallbackText: "notice 2", event: .userJoined(userID: joiner, admittedBy: admitter)), .system(fallbackText: "notice 3", event: nil), + .system(fallbackText: "notice 4", event: .userJoinedViaInvite(userID: joiner, invitedBy: admitter)), + .system(fallbackText: "notice 5", event: .userJoinedViaInvite(userID: joiner, invitedBy: nil)), ]) }